diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 56eb345b8f..38a8d8b26b 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -503,6 +503,7 @@ const loadTools = async ({ requestScopedConnections, res: options.res, streamId: options.req?._resumableStreamId || null, + jobCreatedAt: options.jobCreatedAt, model: agent?.model ?? model, serverName: config.serverName, provider: agent?.provider ?? endpoint, diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index a812443faa..50be5798ec 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -319,6 +319,7 @@ describe('Tool Handlers', () => { const serverName = 'body-scoped'; const toolKey = `search${Constants.mcp_delimiter}${serverName}`; const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' }; + const jobCreatedAt = 1234; const serverConfig = { type: 'streamable-http', url: 'https://api.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', @@ -336,6 +337,7 @@ describe('Tool Handlers', () => { user: { id: fakeUser._id.toString(), role: 'USER' }, body: requestBody, }, + jobCreatedAt, }, }); @@ -348,6 +350,7 @@ describe('Tool Handlers', () => { expect(mockCreateMCPTool).toHaveBeenCalledWith( expect.objectContaining({ requestBody, + jobCreatedAt, toolKey, config: serverConfig, }), diff --git a/api/package.json b/api/package.json index ec06eda2cd..bf62ef945e 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.68", + "@librechat/agents": "^3.3.2", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -102,7 +102,7 @@ "mime": "^3.0.0", "module-alias": "^2.2.3", "mongodb": "^6.14.2", - "mongoose": "^8.23.1", + "mongoose": "^8.24.1", "multer": "^2.2.0", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", @@ -137,7 +137,7 @@ "@types/sanitize-html": "^2.13.0", "jest": "^30.2.0", "mongodb-memory-server": "^11.0.1", - "nodemon": "^3.0.3", + "nodemon": "^3.1.14", "supertest": "^7.1.0" } } diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index 433e1dc2e8..a183a212f5 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -7,6 +7,9 @@ jest.mock('nanoid', () => ({ jest.mock('@librechat/api', () => ({ sendEvent: jest.fn(), + GenerationJobManager: { + emitChunk: jest.fn(), + }, HOST_FILE_AUTHORING_ARTIFACT_KEY: '__librechat_file_authoring', getToolInputValidationDetails: jest.fn((result, validationError) => validationError != null @@ -74,6 +77,61 @@ jest.mock('~/server/services/Files/process', () => ({ saveBase64Image: jest.fn(), })); +describe('resumable event generation fencing', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards the originating job epoch with run-step events', async () => { + const { GenerationJobManager } = require('@librechat/api'); + const { GraphEvents } = jest.requireActual('@librechat/agents'); + const { getDefaultHandlers } = require('../callbacks'); + const data = { + id: 'step-1', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-1', name: 'approval_probe', args: '{}' }], + }, + }; + const handlers = getDefaultHandlers({ + res: { write: jest.fn() }, + aggregateContent: jest.fn(), + toolEndCallback: jest.fn(), + collectedUsage: [], + streamId: 'conversation-1', + jobCreatedAt: 1234, + }); + + await handlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, data); + + expect(GenerationJobManager.emitChunk).toHaveBeenCalledWith( + 'conversation-1', + { event: GraphEvents.ON_RUN_STEP, data }, + { expectedCreatedAt: 1234 }, + ); + }); + + it('forwards the originating job epoch with deferred attachments', () => { + const { GenerationJobManager } = require('@librechat/api'); + const { createAttachmentEmitter } = require('../callbacks'); + const attachment = { file_id: 'file-1', status: 'ready' }; + const emitAttachment = createAttachmentEmitter({ + res: { write: jest.fn() }, + streamId: 'conversation-1', + jobCreatedAt: 1234, + }); + + emitAttachment(attachment); + + expect(GenerationJobManager.emitChunk).toHaveBeenCalledWith( + 'conversation-1', + { event: 'attachment', data: attachment }, + { expectedCreatedAt: 1234 }, + ); + }); +}); + describe('createToolEndCallback', () => { let req, res, artifactPromises, createToolEndCallback; let logger; diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index f4a82052ac..7d9eb04813 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -805,6 +805,33 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { ); }); + it('passes persisted run steps into the rebuilt run for tool-result correlation', async () => { + const runSteps = [ + { + id: 'step-approval', + index: 1, + type: 'tool_calls', + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'tc1', name: 'approval_probe', args: '{}' }], + }, + usage: null, + }, + ]; + mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob()); + mockGenerationJobManager.getResumeState.mockResolvedValue({ + aggregatedContent: [], + runSteps, + }); + + await post(approveBody()); + await settled; + await flush(); + + const client = await mockInitializeClient.mock.results[0].value.then((r) => r.client); + expect(client.resumeCompletion).toHaveBeenCalledWith(expect.objectContaining({ runSteps })); + }); + it('restores the paused user message files before reconstruction (execute-code files)', async () => { mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob()); // The resume body carries no files; the controller must source them from the diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 1c04253481..f451954c5b 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -222,11 +222,12 @@ function checkIfLastAgent(last_agent_id, langgraph_node) { * @param {ServerResponse} res - The server response object * @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode * @param {Object} eventData - The event data to send + * @param {number} [expectedCreatedAt] - The generation epoch that produced the event * @returns {Promise} */ -async function emitEvent(res, streamId, eventData) { +async function emitEvent(res, streamId, eventData, expectedCreatedAt) { if (streamId) { - await GenerationJobManager.emitChunk(streamId, eventData); + await GenerationJobManager.emitChunk(streamId, eventData, { expectedCreatedAt }); } else { sendEvent(res, eventData); } @@ -239,13 +240,12 @@ async function emitEvent(res, streamId, eventData) { * running state. Only signals while a fired prewarm remains unresolved * ({@link shouldSignalSandboxStart}); stateless deployments never fire one * and completed boots clear the marker, so both stay on the generic label. - * @param {ServerResponse} res - The server response object - * @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode + * @param {(eventData: Object) => Promise} emitForJob - Generation-fenced event emitter * @param {StreamEventData} data - The `on_run_step` event data * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata * @returns {Promise} */ -async function maybeEmitSandboxStarting(res, streamId, data, metadata) { +async function maybeEmitSandboxStarting(emitForJob, data, metadata) { const conversationId = metadata?.thread_id; if (!conversationId || !(await shouldSignalSandboxStart(conversationId))) { return; @@ -256,7 +256,7 @@ async function maybeEmitSandboxStarting(res, streamId, data, metadata) { if (!toolCall?.id || name == null || !isCodeSessionToolName(name)) { continue; } - await emitEvent(res, streamId, { + await emitForJob({ event: StepEvents.ON_SANDBOX_STARTING, data: { tool_call_id: toolCall.id, runId: metadata?.run_id }, }); @@ -323,6 +323,7 @@ function feedSubagentAggregator(aggregator, event) { * @param {ToolEndCallback} options.toolEndCallback - Callback to use when tool ends. * @param {Array} options.collectedUsage - The list of collected usage metadata. * @param {string | null} [options.streamId] - The stream ID for resumable mode, or null for standard mode. + * @param {number} [options.jobCreatedAt] - The generation epoch that owns emitted events. * @param {ToolExecuteOptions} [options.toolExecuteOptions] - Options for event-driven tool execution. * @param {UsageCostDeps} [options.usageCost] - Pricing context for authoritative per-event cost. * @param {{ latest: TContextUsageEvent | null, count: number }} [options.contextUsageSink] - Mutable @@ -343,6 +344,7 @@ function getDefaultHandlers({ collectedUsage, collectedThoughtSignatures = null, streamId = null, + jobCreatedAt, toolExecuteOptions = null, summarizationOptions = null, subagentAggregatorsByToolCallId = null, @@ -355,6 +357,7 @@ function getDefaultHandlers({ `[getDefaultHandlers] Missing required options: res: ${!res}, aggregateContent: ${!aggregateContent}`, ); } + const emitForJob = (eventData) => emitEvent(res, streamId, eventData, jobCreatedAt); /** * Emit a token-usage event, attaching the authoritative per-event USD cost * when cost display is enabled. The backend is the single source of truth @@ -385,7 +388,7 @@ function getDefaultHandlers({ if (usageEmitSink) { usageEmitSink.push(payload); } - return emitEvent(res, streamId, { event: UsageEvents.ON_TOKEN_USAGE, data: payload }); + return emitForJob({ event: UsageEvents.ON_TOKEN_USAGE, data: payload }); }; const handlers = { [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler( @@ -404,17 +407,17 @@ function getDefaultHandlers({ handle: async (event, data, metadata) => { aggregateContent({ event, data }); if (data?.stepDetails.type === StepTypes.TOOL_CALLS) { - await emitEvent(res, streamId, { event, data }); - await maybeEmitSandboxStarting(res, streamId, data, metadata); + await emitForJob({ event, data }); + await maybeEmitSandboxStarting(emitForJob, data, metadata); } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } else if (!metadata?.hide_sequential_outputs) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } else { const agentName = metadata?.name ?? 'Agent'; const isToolCall = data?.stepDetails.type === StepTypes.TOOL_CALLS; const action = isToolCall ? 'performing a task...' : 'thinking...'; - await emitEvent(res, streamId, { + await emitForJob({ event: 'on_agent_update', data: { runId: metadata?.run_id, @@ -434,11 +437,11 @@ function getDefaultHandlers({ handle: async (event, data, metadata) => { aggregateContent({ event, data }); if (data?.delta.type === StepTypes.TOOL_CALLS) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } else if (!metadata?.hide_sequential_outputs) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } }, }, @@ -477,11 +480,11 @@ function getDefaultHandlers({ } } if (data?.result != null) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } else if (!metadata?.hide_sequential_outputs) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } }, }, @@ -495,9 +498,9 @@ function getDefaultHandlers({ handle: async (event, data, metadata) => { aggregateContent({ event, data }); if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } else if (!metadata?.hide_sequential_outputs) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } }, }, @@ -511,9 +514,9 @@ function getDefaultHandlers({ handle: async (event, data, metadata) => { aggregateContent({ event, data }); if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } else if (!metadata?.hide_sequential_outputs) { - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } }, }, @@ -568,14 +571,14 @@ function getDefaultHandlers({ ); } } - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); }, }; if (summarizationOptions?.enabled !== false) { handlers[GraphEvents.ON_SUMMARIZE_START] = { handle: async (_event, data) => { - await emitEvent(res, streamId, { + await emitForJob({ event: GraphEvents.ON_SUMMARIZE_START, data, }); @@ -584,7 +587,7 @@ function getDefaultHandlers({ handlers[GraphEvents.ON_SUMMARIZE_DELTA] = { handle: async (_event, data) => { aggregateContent({ event: GraphEvents.ON_SUMMARIZE_DELTA, data }); - await emitEvent(res, streamId, { + await emitForJob({ event: GraphEvents.ON_SUMMARIZE_DELTA, data, }); @@ -593,7 +596,7 @@ function getDefaultHandlers({ handlers[GraphEvents.ON_SUMMARIZE_COMPLETE] = { handle: async (_event, data) => { aggregateContent({ event: GraphEvents.ON_SUMMARIZE_COMPLETE, data }); - await emitEvent(res, streamId, { + await emitForJob({ event: GraphEvents.ON_SUMMARIZE_COMPLETE, data, }); @@ -634,7 +637,7 @@ function getDefaultHandlers({ contextUsageSink.count = (contextUsageSink.count ?? 0) + 1; contextUsageSink.latestUsageIndex = usageEmitSink?.length ?? 0; } - await emitEvent(res, streamId, { event, data }); + await emitForJob({ event, data }); } }, }; @@ -649,10 +652,15 @@ function getDefaultHandlers({ * @param {ServerResponse} res - The server response object * @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode * @param {Object} attachment - The attachment data + * @param {number} [expectedCreatedAt] - The generation epoch that produced the attachment */ -function writeAttachment(res, streamId, attachment) { +function writeAttachment(res, streamId, attachment, expectedCreatedAt) { if (streamId) { - GenerationJobManager.emitChunk(streamId, { event: 'attachment', data: attachment }); + GenerationJobManager.emitChunk( + streamId, + { event: 'attachment', data: attachment }, + { expectedCreatedAt }, + ); } else { res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`); } @@ -699,12 +707,13 @@ function isStreamWritable(res, streamId) { * @param {ServerResponse} res * @param {string | null} streamId * @param {Object} attachment - Updated attachment payload (must carry `file_id`). + * @param {number} [expectedCreatedAt] - The generation epoch that produced the attachment */ -function writeAttachmentUpdate(res, streamId, attachment) { +function writeAttachmentUpdate(res, streamId, attachment, expectedCreatedAt) { if (!isStreamWritable(res, streamId)) { return; } - writeAttachment(res, streamId, attachment); + writeAttachment(res, streamId, attachment, expectedCreatedAt); } /** @@ -714,9 +723,10 @@ function writeAttachmentUpdate(res, streamId, attachment) { * @param {ServerResponse} params.res * @param {Promise[]} params.artifactPromises * @param {string | null} [params.streamId] - The stream ID for resumable mode, or null for standard mode. + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted attachments. * @returns {ToolEndCallback} The tool end callback. */ -function createToolEndCallback({ req, res, artifactPromises, streamId = null }) { +function createToolEndCallback({ req, res, artifactPromises, streamId = null, jobCreatedAt }) { /** * @type {ToolEndCallback} */ @@ -747,7 +757,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) if (!streamId && !res.headersSent) { return attachment; } - writeAttachment(res, streamId, attachment); + writeAttachment(res, streamId, attachment, jobCreatedAt); return attachment; })().catch((error) => { logger.error('Error processing file citations:', error); @@ -769,7 +779,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) if (!streamId && !res.headersSent) { return attachment; } - writeAttachment(res, streamId, attachment); + writeAttachment(res, streamId, attachment, jobCreatedAt); return attachment; })().catch((error) => { logger.error('Error processing artifact content:', error); @@ -791,7 +801,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) if (!streamId && !res.headersSent) { return attachment; } - writeAttachment(res, streamId, attachment); + writeAttachment(res, streamId, attachment, jobCreatedAt); return attachment; })().catch((error) => { logger.error('Error processing artifact content:', error); @@ -813,7 +823,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) if (!streamId && !res.headersSent) { return attachment; } - writeAttachment(res, streamId, attachment); + writeAttachment(res, streamId, attachment, jobCreatedAt); return attachment; })().catch((error) => { logger.error('Error processing memory artifact content:', error); @@ -858,7 +868,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) return null; } - writeAttachment(res, streamId, fileMetadata); + writeAttachment(res, streamId, fileMetadata, jobCreatedAt); return fileMetadata; })().catch((error) => { logger.error('Error processing artifact content:', error); @@ -935,7 +945,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) * IIFE catch but logged as noise). Same gate the Responses * path uses below. */ if (isStreamWritable(res, streamId)) { - writeAttachment(res, streamId, fileMetadata); + writeAttachment(res, streamId, fileMetadata, jobCreatedAt); } /* Deferred preview rendering: extraction continues running * even after the HTTP response closes. If the stream is still @@ -958,11 +968,16 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) fileId: fileMetadata.file_id, previewRevision: result?.previewRevision, onResolved: (updated) => { - writeAttachmentUpdate(res, streamId, { - ...updated, - messageId: metadata.run_id, - toolCallId, - }); + writeAttachmentUpdate( + res, + streamId, + { + ...updated, + messageId: metadata.run_id, + toolCallId, + }, + jobCreatedAt, + ); }, }); return fileMetadata; @@ -983,14 +998,15 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) * @param {Object} params * @param {ServerResponse} params.res * @param {string | null} [params.streamId] + * @param {number} [params.jobCreatedAt] * @returns {(attachment: Object) => void} */ -function createAttachmentEmitter({ res, streamId = null }) { +function createAttachmentEmitter({ res, streamId = null, jobCreatedAt }) { return (attachment) => { if (!attachment || !isStreamWritable(res, streamId)) { return; } - writeAttachment(res, streamId, attachment); + writeAttachment(res, streamId, attachment, jobCreatedAt); }; } diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index bb818adbbd..44cde3d5a7 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -44,6 +44,7 @@ const { deleteAgentCheckpoint, agentRequestsAskUserQuestion, attachAskUserQuestionArgs, + hydrateResumeRunSteps, createContentIndexOffsetHandlers, createSteerIndexOffsetHandlers, createSteerDrainHook, @@ -141,6 +142,7 @@ class AgentClient extends BaseClient { const { agentConfigs, contentParts, + stepMap, collectedUsage, collectedThoughtSignatures, artifactPromises, @@ -170,6 +172,10 @@ class AgentClient extends BaseClient { this.toolInputValidationErrors = toolInputValidationErrors; /** @type {MessageContentComplex[]} */ this.contentParts = contentParts; + /** Original run-step identity used by the content aggregator to attach + * completion events to their rendered content indices. + * @type {Map | undefined} */ + this.stepMap = stepMap; /** @type {Array} */ this.collectedUsage = collectedUsage; /** Vertex Gemini 3 thought signatures captured during the run, keyed by @@ -303,7 +309,7 @@ class AgentClient extends BaseClient { conversationId: this.conversationId, }, }, - { durable: true }, + { durable: true, expectedCreatedAt: this.jobCreatedAt }, ); } @@ -984,6 +990,7 @@ class AgentClient extends BaseClient { config, messageId, streamId, + jobCreatedAt: this.jobCreatedAt, conversationId, memoryMethods: { setMemory: db.setMemory, @@ -1334,10 +1341,14 @@ class AgentClient extends BaseClient { const emit = (async () => { try { if (streamId) { - await GenerationJobManager.emitChunk(streamId, { - event: UsageEvents.ON_TOKEN_USAGE, - data, - }); + await GenerationJobManager.emitChunk( + streamId, + { + event: UsageEvents.ON_TOKEN_USAGE, + data, + }, + { expectedCreatedAt: this.jobCreatedAt }, + ); } else { sendEvent(res, { event: UsageEvents.ON_TOKEN_USAGE, data }); } @@ -1544,10 +1555,14 @@ class AgentClient extends BaseClient { logger.error(`[AgentClient] Failed to release request slot on pause ${streamId}`, err); } } - await GenerationJobManager.emitChunk(streamId, { - event: ApprovalEvents.ON_PENDING_ACTION, - data: toClientPendingAction(pendingAction), - }); + await GenerationJobManager.emitChunk( + streamId, + { + event: ApprovalEvents.ON_PENDING_ACTION, + data: toClientPendingAction(pendingAction), + }, + { expectedCreatedAt: this.jobCreatedAt }, + ); // Steers queued before this pause stay IN the store for the whole approval // window: `resumeState.pendingSteers` re-seeds the client's chips on // reload, and the resumed run drains them at its first tool boundary. @@ -2078,12 +2093,14 @@ class AgentClient extends BaseClient { * @param {object} params * @param {Agents.ToolApprovalDecisionMap | { answer: string }} params.resumeValue * @param {Array} [params.seedContent] - content aggregated before the pause + * @param {Array} [params.runSteps] - run steps emitted before the pause * @param {AbortController} [params.abortController] * @param {Pick} [params.commandOptions] */ async resumeCompletion({ resumeValue, seedContent = [], + runSteps = [], abortController = null, commandOptions, userMCPAuthMap, @@ -2214,6 +2231,8 @@ class AgentClient extends BaseClient { throw new Error('Failed to create run for resume'); } + hydrateResumeRunSteps(runSteps, this.stepMap, run.Graph, seedContent); + this.run = run; if (this._resolveRun) { this._resolveRun(run); diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index f07ec03e9a..954c45b747 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -2922,6 +2922,29 @@ describe('AgentClient - titleConvo', () => { ); }); + it('should bind memory processing to the current generation epoch', async () => { + mockReq._resumableStreamId = 'convo-123'; + mockCheckAccess.mockResolvedValue(true); + mockInitializeAgent.mockResolvedValue({ + ...mockAgent, + provider: EModelEndpoint.openAI, + }); + mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]); + + client = new AgentClient({ ...mockOptions, jobCreatedAt: 1234 }); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + + await client.useMemory(); + + expect(mockCreateMemoryProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + streamId: 'convo-123', + jobCreatedAt: 1234, + }), + ); + }); + it('should load different agent when memory config agent.id differs from current agent id', async () => { const differentAgentId = 'different-agent-456'; const differentAgent = { diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 3f56bc84cb..9b60ee1094 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -723,13 +723,17 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit if (titleAbortController.signal.aborted) { return; } - await GenerationJobManager.emitChunk(streamId, { - event: 'title', - data: { - conversationId: titleConversationId, - title, + await GenerationJobManager.emitChunk( + streamId, + { + event: 'title', + data: { + conversationId: titleConversationId, + title, + }, }, - }); + { expectedCreatedAt: jobCreatedAt }, + ); })().catch((err) => { logger.error('[ResumableAgentController] Error emitting title event', err); }); @@ -774,27 +778,31 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit logger.error('[ResumableAgentController] Failed to persist start metadata', err); }); - GenerationJobManager.emitChunk(streamId, { - created: true, - // Skill selections aren't on `userMessage` yet at onStart (BaseClient adds - // them later), so attach them from the request — this is the message - // `trackUserMessage` persists as the authoritative job.metadata.userMessage, - // and it's what the live client renders the user bubble from. - message: { - ...userMessage, - // Carry files so trackUserMessage (the authoritative writer) persists them on - // job.metadata.userMessage for a HITL resume (see the updateMetadata above). - ...(Array.isArray(req.body?.files) && - req.body.files.length > 0 && { files: req.body.files }), - ...(Array.isArray(req.body?.manualSkills) && - req.body.manualSkills.length > 0 && { manualSkills: req.body.manualSkills }), - ...(Array.isArray(req.body?.alwaysAppliedSkills) && - req.body.alwaysAppliedSkills.length > 0 && { - alwaysAppliedSkills: req.body.alwaysAppliedSkills, - }), - }, + GenerationJobManager.emitChunk( streamId, - }).catch((err) => { + { + created: true, + // Skill selections aren't on `userMessage` yet at onStart (BaseClient adds + // them later), so attach them from the request — this is the message + // `trackUserMessage` persists as the authoritative job.metadata.userMessage, + // and it's what the live client renders the user bubble from. + message: { + ...userMessage, + // Carry files so trackUserMessage (the authoritative writer) persists them on + // job.metadata.userMessage for a HITL resume (see the updateMetadata above). + ...(Array.isArray(req.body?.files) && + req.body.files.length > 0 && { files: req.body.files }), + ...(Array.isArray(req.body?.manualSkills) && + req.body.manualSkills.length > 0 && { manualSkills: req.body.manualSkills }), + ...(Array.isArray(req.body?.alwaysAppliedSkills) && + req.body.alwaysAppliedSkills.length > 0 && { + alwaysAppliedSkills: req.body.alwaysAppliedSkills, + }), + }, + streamId, + }, + { expectedCreatedAt: jobCreatedAt }, + ).catch((err) => { logger.error('[ResumableAgentController] Failed to queue created event', err); }); }; diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index d4387e7d89..d8768bf168 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -346,10 +346,14 @@ async function finalizeResumedTurn({ client, onTitleGenerated: ({ conversationId: titleConvoId, title }) => { conversation.title = title; - return GenerationJobManager.emitChunk(streamId, { - event: 'title', - data: { conversationId: titleConvoId, title }, - }); + return GenerationJobManager.emitChunk( + streamId, + { + event: 'title', + data: { conversationId: titleConvoId, title }, + }, + { expectedCreatedAt: job.createdAt }, + ); }, }); } catch (err) { @@ -843,6 +847,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) await client.resumeCompletion({ resumeValue: mapped.resumeValue, seedContent, + runSteps: resumeState?.runSteps ?? [], abortController: job.abortController, // Carry the user's MCP auth so approved MCP tools run with their credentials. userMCPAuthMap: result.userMCPAuthMap, diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 32de58835d..79d5527f1e 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -8,6 +8,7 @@ const { agentUpdateSchema, refreshListAvatars, collectEdgeAgentIds, + replaceEdgeSourceId, mergeDeploymentSkillIds, mergeAgentOcrConversion, sanitizeModelParameters, @@ -143,18 +144,30 @@ const classifyAgentReferences = async (agentIds, userId, userRole) => { }; /** - * Validates VIEW access for every agent referenced in `edges`. - * Missing ids are NOT errors here — at create time a self-referential - * `from` often names the agent being built, which has no DB record - * yet. Only unauthorized (existing but unviewable) ids are returned. + * Validates that every agent referenced in `edges` exists and is viewable. + * The create path may allow its newly generated self id because that agent + * has not been inserted yet; all other missing references are invalid. + * @param {GraphEdge[]} edges + * @param {string} userId + * @param {string} userRole + * @param {Set} [allowedMissingIds] + * @returns {Promise<{ missing: string[], unauthorized: string[] }>} */ -const validateEdgeAgentAccess = async (edges, userId, userRole) => { - const { unauthorized } = await classifyAgentReferences( +const validateEdgeAgentReferences = async ( + edges, + userId, + userRole, + allowedMissingIds = new Set(), +) => { + const { missing, unauthorized } = await classifyAgentReferences( collectEdgeAgentIds(edges), userId, userRole, ); - return unauthorized; + return { + missing: missing.filter((id) => !allowedMissingIds.has(id)), + unauthorized, + }; }; /** @@ -366,6 +379,8 @@ const createAgentHandler = async (req, res) => { } const { id: userId, role: userRole } = req.user; + agentData.id = `agent_${nanoid()}`; + agentData.edges = replaceEdgeSourceId(agentData.edges, '', agentData.id); if (agentData.tool_resources) { await pruneToolResourceFileIdsForAgent({ @@ -376,7 +391,18 @@ const createAgentHandler = async (req, res) => { } if (agentData.edges?.length) { - const unauthorized = await validateEdgeAgentAccess(agentData.edges, userId, userRole); + const { missing, unauthorized } = await validateEdgeAgentReferences( + agentData.edges, + userId, + userRole, + new Set([agentData.id]), + ); + if (missing.length > 0) { + return res.status(400).json({ + error: 'One or more agents referenced in edges do not exist', + agent_ids: missing, + }); + } if (unauthorized.length > 0) { return res.status(403).json({ error: 'You do not have access to one or more agents referenced in edges', @@ -423,7 +449,6 @@ const createAgentHandler = async (req, res) => { } } - agentData.id = `agent_${nanoid()}`; agentData.author = userId; agentData.tools = []; @@ -629,9 +654,23 @@ const updateAgentHandler = async (req, res) => { updateData.avatar = avatarField; } + if (updateData.edges !== undefined) { + updateData.edges = replaceEdgeSourceId(updateData.edges, '', id); + } + if (updateData.edges?.length) { const { id: userId, role: userRole } = req.user; - const unauthorized = await validateEdgeAgentAccess(updateData.edges, userId, userRole); + const { missing, unauthorized } = await validateEdgeAgentReferences( + updateData.edges, + userId, + userRole, + ); + if (missing.length > 0) { + return res.status(400).json({ + error: 'One or more agents referenced in edges do not exist', + agent_ids: missing, + }); + } if (unauthorized.length > 0) { return res.status(403).json({ error: 'You do not have access to one or more agents referenced in edges', @@ -802,7 +841,7 @@ const updateAgentHandler = async (req, res) => { */ const duplicateAgentHandler = async (req, res) => { const { id } = req.params; - const { id: userId } = req.user; + const { id: userId, role: userRole } = req.user; const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret']; try { @@ -852,6 +891,29 @@ const duplicateAgentHandler = async (req, res) => { id: newAgentId, author: userId, }); + newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, id, newAgentId); + newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, '', newAgentId); + + if (newAgentData.edges?.length) { + const { missing, unauthorized } = await validateEdgeAgentReferences( + newAgentData.edges, + userId, + userRole, + new Set([newAgentId]), + ); + if (missing.length > 0) { + return res.status(400).json({ + error: 'One or more agents referenced in edges do not exist', + agent_ids: missing, + }); + } + if (unauthorized.length > 0) { + return res.status(403).json({ + error: 'You do not have access to one or more agents referenced in edges', + agent_ids: unauthorized, + }); + } + } const newActionsList = []; const originalActions = (await db.getActions({ agent_id: id }, true)) ?? []; @@ -1274,10 +1336,42 @@ const revertAgentVersionHandler = async (req, res) => { return res.status(404).json({ error: 'Agent not found' }); } + const revertVersion = existingAgent.versions?.[version_index]; + const storedRevertEdges = Array.isArray(revertVersion?.edges) ? revertVersion.edges : []; + const revertEdges = replaceEdgeSourceId(storedRevertEdges, '', id); + const hasLegacyEdgeSource = storedRevertEdges.some((edge) => + Array.isArray(edge.from) ? edge.from.includes('') : edge.from === '', + ); + if (revertEdges.length > 0) { + const { missing, unauthorized } = await validateEdgeAgentReferences( + revertEdges, + req.user.id, + req.user.role, + ); + if (missing.length > 0) { + return res.status(400).json({ + error: 'One or more agents referenced in edges do not exist', + agent_ids: missing, + }); + } + if (unauthorized.length > 0) { + return res.status(403).json({ + error: 'You do not have access to one or more agents referenced in edges', + agent_ids: unauthorized, + }); + } + } + // Permissions are enforced via route middleware (ACL EDIT) let updatedAgent = await db.revertAgentVersion({ id }, version_index); const revertUpdates = {}; + if ( + revertVersion && + (hasLegacyEdgeSource || (!Array.isArray(revertVersion.edges) && updatedAgent.edges?.length)) + ) { + revertUpdates.edges = revertEdges; + } if (updatedAgent.tools?.length) { const [availableTools, configServers] = await Promise.all([ diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 9c4187b6ab..eecae7e537 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -2475,7 +2475,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => { name: 'Attacker Agent', provider: 'openai', model: 'gpt-4', - edges: [{ from: 'self_placeholder', to: targetAgent.id, edgeType: 'handoff' }], + edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }], }; await createAgentHandler(mockReq, mockRes); @@ -2493,25 +2493,33 @@ describe('Agent Controllers - Mass Assignment Protection', () => { name: 'Legit Agent', provider: 'openai', model: 'gpt-4', - edges: [{ from: 'self_placeholder', to: targetAgent.id, edgeType: 'handoff' }], + edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }], }; await createAgentHandler(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(201); + const response = mockRes.json.mock.calls[0][0]; + expect(response.edges).toEqual([ + { from: response.id, to: targetAgent.id, edgeType: 'handoff' }, + ]); }); - test('createAgentHandler should allow edges referencing non-existent agents (self-reference at create time)', async () => { + test('createAgentHandler should reject a non-existent handoff target', async () => { mockReq.body = { - name: 'Self-Ref Agent', + name: 'Dangling Edge Agent', provider: 'openai', model: 'gpt-4', - edges: [{ from: 'agent_does_not_exist_yet', to: 'agent_also_new', edgeType: 'handoff' }], + edges: [{ from: '', to: 'agent_missing_target', edgeType: 'handoff' }], }; await createAgentHandler(mockReq, mockRes); - expect(mockRes.status).toHaveBeenCalledWith(201); + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'One or more agents referenced in edges do not exist', + agent_ids: ['agent_missing_target'], + }); }); test('updateAgentHandler should return 403 when user lacks VIEW on an edge-referenced agent', async () => { @@ -2540,6 +2548,42 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.agent_ids).not.toContain(ownedAgent.id); }); + test('updateAgentHandler should repair a legacy empty handoff source', async () => { + const ownedAgent = await Agent.create({ + id: `agent_${nanoid()}`, + author: mockReq.user.id, + name: 'Legacy Router', + provider: 'openai', + model: 'gpt-4', + tools: [], + edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }], + }); + getResourcePermissionsMap.mockResolvedValueOnce( + new Map([ + [ownedAgent._id.toString(), PermissionBits.VIEW], + [targetAgent._id.toString(), PermissionBits.VIEW], + ]), + ); + + mockReq.params = { id: ownedAgent.id }; + mockReq.body = { + edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }], + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).not.toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + edges: [{ from: ownedAgent.id, to: targetAgent.id, edgeType: 'handoff' }], + }), + ); + const persisted = await Agent.findOne({ id: ownedAgent.id }).lean(); + expect(persisted.edges).toEqual([ + { from: ownedAgent.id, to: targetAgent.id, edgeType: 'handoff' }, + ]); + }); + test('updateAgentHandler should succeed when edges field is absent from payload', async () => { const ownedAgent = await Agent.create({ id: `agent_${nanoid()}`, @@ -2559,5 +2603,238 @@ describe('Agent Controllers - Mass Assignment Protection', () => { const response = mockRes.json.mock.calls[0][0]; expect(response.name).toBe('Renamed Agent'); }); + + test('duplicateAgentHandler should move current and legacy handoff sources to the clone', async () => { + const sourceAgentId = `agent_${nanoid()}`; + const secondTarget = await Agent.create({ + id: `agent_${nanoid()}`, + author: new mongoose.Types.ObjectId().toString(), + name: 'Second Target Agent', + provider: 'openai', + model: 'gpt-4', + tools: [], + }); + const sourceAgent = await Agent.create({ + id: sourceAgentId, + author: mockReq.user.id, + name: 'Legacy Clone Source', + provider: 'openai', + model: 'gpt-4', + tools: [], + edges: [ + { from: sourceAgentId, to: targetAgent.id, edgeType: 'handoff' }, + { from: '', to: secondTarget.id, edgeType: 'handoff' }, + ], + }); + getResourcePermissionsMap.mockResolvedValueOnce( + new Map([ + [targetAgent._id.toString(), PermissionBits.VIEW], + [secondTarget._id.toString(), PermissionBits.VIEW], + ]), + ); + jest.spyOn(require('~/models'), 'getActions').mockResolvedValueOnce([]); + + mockReq.params = { id: sourceAgent.id }; + + await duplicateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(201); + const { agent } = mockRes.json.mock.calls[0][0]; + expect(agent.edges).toEqual([ + { from: agent.id, to: targetAgent.id, edgeType: 'handoff' }, + { from: agent.id, to: secondTarget.id, edgeType: 'handoff' }, + ]); + }); + + test('duplicateAgentHandler should return 400 for a missing handoff target', async () => { + const missingTargetId = `agent_${nanoid()}`; + const sourceAgent = await Agent.create({ + id: `agent_${nanoid()}`, + author: mockReq.user.id, + name: 'Stale Clone Source', + provider: 'openai', + model: 'gpt-4', + tools: [], + edges: [{ from: '', to: missingTargetId, edgeType: 'handoff' }], + }); + + mockReq.params = { id: sourceAgent.id }; + + await duplicateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'One or more agents referenced in edges do not exist', + agent_ids: [missingTargetId], + }); + expect(await Agent.countDocuments()).toBe(2); + }); + + test('duplicateAgentHandler should return 403 without VIEW access to a handoff target', async () => { + const sourceAgentId = `agent_${nanoid()}`; + const sourceAgent = await Agent.create({ + id: sourceAgentId, + author: mockReq.user.id, + name: 'Restricted Clone Source', + provider: 'openai', + model: 'gpt-4', + tools: [], + edges: [{ from: sourceAgentId, to: targetAgent.id, edgeType: 'handoff' }], + }); + getResourcePermissionsMap.mockResolvedValueOnce(new Map()); + + mockReq.params = { id: sourceAgent.id }; + + await duplicateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'You do not have access to one or more agents referenced in edges', + agent_ids: [targetAgent.id], + }); + expect(await Agent.countDocuments()).toBe(2); + }); + + test('revertAgentVersionHandler should clear handoffs when the historical version has none', async () => { + const agentId = `agent_${nanoid()}`; + await Agent.create({ + id: agentId, + author: mockReq.user.id, + name: 'Current Router', + provider: 'openai', + model: 'gpt-4', + tools: [], + edges: [{ from: agentId, to: targetAgent.id, edgeType: 'handoff' }], + versions: [ + { + name: 'Historical Router', + provider: 'openai', + model: 'gpt-4', + tools: [], + }, + ], + }); + + mockReq.params = { id: agentId }; + mockReq.body = { version_index: 0 }; + + await revertAgentVersionHandler(mockReq, mockRes); + + expect(mockRes.status).not.toHaveBeenCalledWith(400); + const persisted = await Agent.findOne({ id: agentId }).lean(); + expect(persisted.name).toBe('Historical Router'); + expect(persisted.edges).toEqual([]); + }); + + test('revertAgentVersionHandler should restore accessible historical handoffs', async () => { + const agentId = `agent_${nanoid()}`; + const sourceAgent = await Agent.create({ + id: agentId, + author: mockReq.user.id, + name: 'Current Router', + provider: 'openai', + model: 'gpt-4', + tools: [], + edges: [], + versions: [ + { + name: 'Historical Router', + provider: 'openai', + model: 'gpt-4', + tools: [], + edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }], + }, + ], + }); + getResourcePermissionsMap.mockResolvedValueOnce( + new Map([ + [sourceAgent._id.toString(), PermissionBits.VIEW], + [targetAgent._id.toString(), PermissionBits.VIEW], + ]), + ); + + mockReq.params = { id: agentId }; + mockReq.body = { version_index: 0 }; + + await revertAgentVersionHandler(mockReq, mockRes); + + expect(mockRes.status).not.toHaveBeenCalledWith(400); + expect(mockRes.status).not.toHaveBeenCalledWith(403); + const persisted = await Agent.findOne({ id: agentId }).lean(); + expect(persisted.name).toBe('Historical Router'); + expect(persisted.edges).toEqual([{ from: agentId, to: targetAgent.id, edgeType: 'handoff' }]); + }); + + test('revertAgentVersionHandler should return 400 before restoring a missing handoff target', async () => { + const agentId = `agent_${nanoid()}`; + const missingTargetId = `agent_${nanoid()}`; + await Agent.create({ + id: agentId, + author: mockReq.user.id, + name: 'Current Router', + provider: 'openai', + model: 'gpt-4', + tools: [], + versions: [ + { + name: 'Stale Historical Router', + provider: 'openai', + model: 'gpt-4', + tools: [], + edges: [{ from: agentId, to: missingTargetId, edgeType: 'handoff' }], + }, + ], + }); + + mockReq.params = { id: agentId }; + mockReq.body = { version_index: 0 }; + + await revertAgentVersionHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'One or more agents referenced in edges do not exist', + agent_ids: [missingTargetId], + }); + const persisted = await Agent.findOne({ id: agentId }).lean(); + expect(persisted.name).toBe('Current Router'); + }); + + test('revertAgentVersionHandler should return 403 before restoring a restricted handoff target', async () => { + const agentId = `agent_${nanoid()}`; + const sourceAgent = await Agent.create({ + id: agentId, + author: mockReq.user.id, + name: 'Current Router', + provider: 'openai', + model: 'gpt-4', + tools: [], + versions: [ + { + name: 'Restricted Historical Router', + provider: 'openai', + model: 'gpt-4', + tools: [], + edges: [{ from: agentId, to: targetAgent.id, edgeType: 'handoff' }], + }, + ], + }); + getResourcePermissionsMap.mockResolvedValueOnce( + new Map([[sourceAgent._id.toString(), PermissionBits.VIEW]]), + ); + + mockReq.params = { id: agentId }; + mockReq.body = { version_index: 0 }; + + await revertAgentVersionHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'You do not have access to one or more agents referenced in edges', + agent_ids: [targetAgent.id], + }); + const persisted = await Agent.findOne({ id: agentId }).lean(); + expect(persisted.name).toBe('Current Router'); + }); }); }); diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index db8a536d8e..c39533fa7e 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -175,6 +175,7 @@ async function loadActionSets(searchParams) { * @param {import('zod').ZodTypeAny | undefined} [params.zodSchema] - The Zod schema for tool input validation/definition * @param {{ oauth_client_id?: string; oauth_client_secret?: string; }} params.encrypted - The encrypted values for the action. * @param {string | null} [params.streamId] - The stream ID for resumable streams. + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted events. * @param {boolean} [params.useSSRFProtection] - When true, uses SSRF-safe HTTP agents that validate resolved IPs at connect time. * @param {string[] | null} [params.allowedAddresses] - Optional admin exemption list of host:port pairs that bypass the SSRF private-IP block. * @returns { Promise unknown}> } An object with `_call` method to execute the tool input. @@ -189,6 +190,7 @@ async function createActionTool({ description, encrypted, streamId = null, + jobCreatedAt, useSSRFProtection = false, allowedAddresses, }) { @@ -250,7 +252,9 @@ async function createActionTool({ async () => { const eventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data }; if (streamId) { - await GenerationJobManager.emitChunk(streamId, eventData); + await GenerationJobManager.emitChunk(streamId, eventData, { + expectedCreatedAt: jobCreatedAt, + }); } else { sendEvent(res, eventData); } @@ -281,7 +285,9 @@ async function createActionTool({ data.delta.expires_at = undefined; const successEventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data }; if (streamId) { - await GenerationJobManager.emitChunk(streamId, successEventData); + await GenerationJobManager.emitChunk(streamId, successEventData, { + expectedCreatedAt: jobCreatedAt, + }); } else { sendEvent(res, successEventData); } diff --git a/api/server/services/ActionService.spec.js b/api/server/services/ActionService.spec.js index 52419975f7..73c4cf6a5a 100644 --- a/api/server/services/ActionService.spec.js +++ b/api/server/services/ActionService.spec.js @@ -1,14 +1,51 @@ const { Constants, actionDelimiter, actionDomainSeparator } = require('librechat-data-provider'); -const { domainParser, legacyDomainEncode, validateAndUpdateTool } = require('./ActionService'); + +const mockEmitChunk = jest.fn(); +const mockFindToken = jest.fn(); +const mockActionFlowManager = { + createFlowWithHandler: jest.fn(), + createFlow: jest.fn(), +}; jest.mock('keyv'); +jest.mock('jsonwebtoken', () => ({ + sign: jest.fn(() => 'signed-state'), +})); + +jest.mock('@librechat/agents', () => ({ + ...jest.requireActual('@librechat/agents'), + sleep: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + validateActionOAuthMetadata: jest.fn().mockResolvedValue(undefined), + GenerationJobManager: { + emitChunk: (...args) => mockEmitChunk(...args), + }, +})); + jest.mock('~/models', () => ({ getActions: jest.fn(), + findToken: (...args) => mockFindToken(...args), + updateToken: jest.fn(), + createToken: jest.fn(), deleteActions: jest.fn(), + deleteAssistant: jest.fn(), +})); + +jest.mock('~/config', () => ({ + getActionFlowStateManager: jest.fn(() => mockActionFlowManager), })); const { getActions } = require('~/models'); +const { + createActionTool, + domainParser, + legacyDomainEncode, + validateAndUpdateTool, +} = require('./ActionService'); let mockDomainCache = {}; jest.mock('~/cache/getLogStores', () => { @@ -24,6 +61,10 @@ jest.mock('~/cache/getLogStores', () => { beforeEach(() => { mockDomainCache = {}; getActions.mockReset(); + mockEmitChunk.mockReset(); + mockFindToken.mockReset(); + mockActionFlowManager.createFlowWithHandler.mockReset(); + mockActionFlowManager.createFlow.mockReset(); }); const SEP = actionDomainSeparator; @@ -202,6 +243,78 @@ describe('legacyDomainEncode', () => { }); }); +describe('createActionTool OAuth events', () => { + it('fences resumable login and completion deltas to the owning job epoch', async () => { + const streamId = 'action-oauth-stream'; + const jobCreatedAt = 1234; + const preparedExecutor = { + setAuth: jest.fn().mockResolvedValue(undefined), + execute: jest.fn().mockResolvedValue({ data: { ok: true } }), + }; + const requestBuilder = { + createExecutor: jest.fn(() => ({ + setParams: jest.fn(() => preparedExecutor), + })), + }; + mockFindToken.mockResolvedValue(null); + mockActionFlowManager.createFlowWithHandler.mockImplementation( + async (_flowId, _type, handler) => handler(), + ); + mockActionFlowManager.createFlow.mockResolvedValue({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 3600, + }); + + const actionTool = await createActionTool({ + userId: 'action-user', + res: {}, + action: { + action_id: 'action-1', + metadata: { + domain: 'https://api.example.com', + oauth_client_id: 'client-id', + auth: { + type: 'oauth', + authorization_url: 'https://auth.example.com/authorize', + client_url: 'https://auth.example.com/token', + scope: 'read', + }, + }, + }, + requestBuilder, + encrypted: { + oauth_client_id: 'encrypted-client-id', + oauth_client_secret: 'encrypted-client-secret', + }, + streamId, + jobCreatedAt, + }); + + await actionTool._call( + {}, + { + metadata: { + thread_id: 'thread-1', + run_id: 'run-1', + }, + toolCall: { + id: 'tool-call-1', + stepId: 'step-1', + name: 'action-tool', + type: 'tool_call', + }, + }, + ); + + expect(mockEmitChunk).toHaveBeenCalledTimes(2); + for (const [emittedStreamId, , options] of mockEmitChunk.mock.calls) { + expect(emittedStreamId).toBe(streamId); + expect(options).toEqual({ expectedCreatedAt: jobCreatedAt }); + } + }); +}); + describe('validateAndUpdateTool', () => { const mockReq = { user: { id: 'user123' } }; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 9cc8cba7d1..cdf86f85ba 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -59,8 +59,9 @@ const db = require('~/models'); * @param {string | null} [streamId] - The stream ID for resumable mode * @param {boolean} [definitionsOnly=false] - When true, returns only serializable * tool definitions without creating full tool instances (for event-driven mode) + * @param {number} [jobCreatedAt] - The generation epoch that owns emitted tool events */ -function createToolLoader(signal, streamId = null, definitionsOnly = false) { +function createToolLoader(signal, streamId = null, definitionsOnly = false, jobCreatedAt) { /** * @param {object} params * @param {ServerRequest} params.req @@ -96,6 +97,7 @@ function createToolLoader(signal, streamId = null, definitionsOnly = false) { agent, signal, streamId, + jobCreatedAt, tool_resources, definitionsOnly, }); @@ -143,7 +145,13 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt /** @type {Map} */ const toolInputValidationErrors = new Map(); const { contentParts, aggregateContent, stepMap } = createContentAggregator(); - const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId }); + const toolEndCallback = createToolEndCallback({ + req, + res, + artifactPromises, + streamId, + jobCreatedAt, + }); /** Query accessible skill IDs once per run (shared across all agents). * Skills activate under strict opt-in semantics — see @@ -269,6 +277,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, + jobCreatedAt, }); logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`); @@ -288,7 +297,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt req, updateToolCallResult: db.updateToolCallResult, }), - emitAttachment: createAttachmentEmitter({ res, streamId }), + emitAttachment: createAttachmentEmitter({ res, streamId, jobCreatedAt }), ...getSkillToolDeps(), }; @@ -337,6 +346,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt collectedUsage, collectedThoughtSignatures, streamId, + jobCreatedAt, subagentAggregatorsByToolCallId, usageCost, contextUsageSink, @@ -364,7 +374,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt const allowedProviders = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders); /** Event-driven mode: only load tool definitions, not full instances */ - const loadTools = createToolLoader(signal, streamId, true); + const loadTools = createToolLoader(signal, streamId, true, jobCreatedAt); /** @type {Array} */ const requestFiles = req.body.files ?? []; /** @type {string} */ @@ -1009,6 +1019,7 @@ const initializeClient = async ({ req, res, signal, endpointOption, jobCreatedAt res, sender, contentParts, + stepMap, agentConfigs, eventHandlers, collectedUsage, diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index dd3a4a86ca..871a7ecc3d 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -42,11 +42,13 @@ jest.mock('@librechat/api', () => ({ * `ON_TOOL_EXECUTE` pipeline with a real subagent id and observe whether * the tool context (agent, tool_resources, skill ACLs) was preserved. */ let capturedToolExecuteOptions; +let capturedDefaultHandlerOptions; jest.mock('~/server/controllers/agents/callbacks', () => ({ createToolEndCallback: jest.fn(() => jest.fn()), createAttachmentEmitter: jest.fn(() => jest.fn()), createBackgroundCodeResultHandler: jest.fn(() => jest.fn()), getDefaultHandlers: jest.fn((opts) => { + capturedDefaultHandlerOptions = opts; capturedToolExecuteOptions = opts?.toolExecuteOptions; return {}; }), @@ -109,6 +111,7 @@ describe('initializeClient — processAgent ACL gate', () => { await mongoose.connection.dropDatabase(); jest.clearAllMocks(); agentClientArgs = undefined; + capturedDefaultHandlerOptions = undefined; testUser = await User.create({ email: 'test@example.com', @@ -151,6 +154,51 @@ describe('initializeClient — processAgent ACL gate', () => { maxContextTokens: 4096, }); + it('threads the owning job epoch into resumable event handlers', async () => { + const { + createAttachmentEmitter, + createToolEndCallback, + } = require('~/server/controllers/agents/callbacks'); + mockInitializeAgent.mockResolvedValue(makePrimaryConfig([])); + const req = makeReq(); + req._resumableStreamId = 'conv_1'; + + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + jobCreatedAt: 1234, + }); + + expect(capturedDefaultHandlerOptions).toEqual( + expect.objectContaining({ + streamId: 'conv_1', + jobCreatedAt: 1234, + }), + ); + expect(createToolEndCallback).toHaveBeenCalledWith( + expect.objectContaining({ + streamId: 'conv_1', + jobCreatedAt: 1234, + }), + ); + expect(createAttachmentEmitter).toHaveBeenCalledWith({ + res: {}, + streamId: 'conv_1', + jobCreatedAt: 1234, + }); + + mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [], configurable: {} }); + await capturedToolExecuteOptions.loadTools([], PRIMARY_ID); + expect(mockLoadToolsForExecution).toHaveBeenCalledWith( + expect.objectContaining({ + streamId: 'conv_1', + jobCreatedAt: 1234, + }), + ); + }); + it('should skip handoff agent and filter its edge when user lacks VIEW access', async () => { await createAgent({ id: TARGET_ID, diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index f6fc35f1ed..9ac936f57c 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -244,8 +244,9 @@ function isEmptyObjectSchema(jsonSchema) { * @param {string} params.stepId - The ID of the step in the flow. * @param {ToolCallChunk} params.toolCall - The tool call object containing tool information. * @param {string | null} [params.streamId] - The stream ID for resumable mode. + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted events. */ -function createRunStepDeltaEmitter({ res, stepId, toolCall, streamId = null }) { +function createRunStepDeltaEmitter({ res, stepId, toolCall, streamId = null, jobCreatedAt }) { /** * @param {string} authURL - The URL to redirect the user for OAuth authentication. * @param {{ expiresAt?: number }} [options] @@ -254,7 +255,9 @@ function createRunStepDeltaEmitter({ res, stepId, toolCall, streamId = null }) { return async function (authURL, options) { const eventData = buildMCPAuthRunStepDeltaEvent({ authURL, stepId, toolCall, options }); if (streamId) { - await GenerationJobManager.emitChunk(streamId, eventData); + await GenerationJobManager.emitChunk(streamId, eventData, { + expectedCreatedAt: jobCreatedAt, + }); } else { sendEvent(res, eventData); } @@ -269,13 +272,24 @@ function createRunStepDeltaEmitter({ res, stepId, toolCall, streamId = null }) { * @param {ToolCallChunk} params.toolCall - The tool call object containing tool information. * @param {number} [params.index] * @param {string | null} [params.streamId] - The stream ID for resumable mode. + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted events. * @returns {() => Promise} */ -function createRunStepEmitter({ res, runId, stepId, toolCall, index, streamId = null }) { +function createRunStepEmitter({ + res, + runId, + stepId, + toolCall, + index, + streamId = null, + jobCreatedAt, +}) { return async function () { const eventData = buildMCPAuthRunStepEvent({ runId, stepId, toolCall, index }); if (streamId) { - await GenerationJobManager.emitChunk(streamId, eventData); + await GenerationJobManager.emitChunk(streamId, eventData, { + expectedCreatedAt: jobCreatedAt, + }); } else { sendEvent(res, eventData); } @@ -333,12 +347,15 @@ function createOAuthStart({ flowId, flowManager, callback }) { * @param {string} params.stepId - The ID of the step in the flow. * @param {ToolCallChunk} params.toolCall - The tool call object containing tool information. * @param {string | null} [params.streamId] - The stream ID for resumable mode. + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted events. */ -function createOAuthEnd({ res, stepId, toolCall, streamId = null }) { +function createOAuthEnd({ res, stepId, toolCall, streamId = null, jobCreatedAt }) { return async function () { const eventData = buildMCPAuthRunStepEndDeltaEvent({ stepId, toolCall }); if (streamId) { - await GenerationJobManager.emitChunk(streamId, eventData); + await GenerationJobManager.emitChunk(streamId, eventData, { + expectedCreatedAt: jobCreatedAt, + }); } else { sendEvent(res, eventData); } @@ -386,6 +403,7 @@ function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) { * @param {string} params.model * @param {number} [params.index] * @param {string | null} [params.streamId] - The stream ID for resumable mode. + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted events. * @param {Record>} [params.userMCPAuthMap] * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] * @param {import('@librechat/api').ParsedServerConfig} [params.serverConfig] - Used to bypass reconnect throttling for request-scoped servers. @@ -403,6 +421,7 @@ async function reconnectServer({ requestBody, requestScopedConnections, streamId = null, + jobCreatedAt, }) { logger.debug( `[MCP][reconnectServer] serverName: ${serverName}, user: ${user?.id}, hasUserMCPAuthMap: ${!!userMCPAuthMap}`, @@ -456,12 +475,14 @@ async function reconnectServer({ stepId, toolCall, streamId, + jobCreatedAt, }); const runStepDeltaEmitter = createRunStepDeltaEmitter({ res, stepId, toolCall, streamId, + jobCreatedAt, }); const callback = createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }); const oauthStart = createOAuthStart({ @@ -508,6 +529,7 @@ async function reconnectServer({ * @param {number} [params.index] * @param {AbortSignal} [params.signal] * @param {string | null} [params.streamId] - The stream ID for resumable mode. + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted events. * @param {import('@librechat/api').ParsedServerConfig} [params.config] * @param {import('@librechat/api').RequestBody} [params.requestBody] * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] @@ -528,6 +550,7 @@ async function createMCPTools({ requestBody, requestScopedConnections, streamId = null, + jobCreatedAt, }) { const serverConfig = config ?? (await getMCPServersRegistry().getServerConfig(serverName, user?.id, configServers)); @@ -567,6 +590,7 @@ async function createMCPTools({ requestBody, requestScopedConnections, streamId, + jobCreatedAt, }); if (result === null) { logger.debug(`[MCP][${serverName}] Reconnect throttled, skipping tool creation.`); @@ -587,6 +611,7 @@ async function createMCPTools({ userMCPAuthMap, configServers, streamId, + jobCreatedAt, availableTools: result.availableTools, toolKey: `${tool.name}${Constants.mcp_delimiter}${serverName}`, requestBody, @@ -619,6 +644,7 @@ async function createMCPTools({ * @param {Record>} [params.userMCPAuthMap] * @param {import('@librechat/api').ParsedServerConfig} [params.config] * @param {(availableTools: LCAvailableTools) => void} [params.onAvailableTools] + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted events. * @returns { Promise unknown}> } An object with `_call` method to execute the tool input. */ async function createMCPTool({ @@ -637,6 +663,7 @@ async function createMCPTool({ configServers, onAvailableTools, streamId = null, + jobCreatedAt, }) { const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter); @@ -694,6 +721,7 @@ async function createMCPTool({ requestBody, requestScopedConnections, streamId, + jobCreatedAt, }); if (result?.availableTools) { onAvailableTools?.(result.availableTools); @@ -725,6 +753,7 @@ async function createMCPTool({ serverConfig, toolDefinition, streamId, + jobCreatedAt, }); } @@ -740,6 +769,7 @@ function createToolInstance({ toolDefinition, provider: capturedProvider, streamId = null, + jobCreatedAt, }) { /** @type {LCTool} */ const { description, parameters } = toolDefinition; @@ -795,6 +825,7 @@ function createToolInstance({ stepId, toolCall, streamId, + jobCreatedAt, }); const oauthStart = createOAuthStart({ flowId, @@ -806,6 +837,7 @@ function createToolInstance({ stepId, toolCall, streamId, + jobCreatedAt, }); if (derivedSignal) { diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 30fbc6442b..1e529189c6 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -39,7 +39,7 @@ jest.mock('@librechat/api', () => { }); const { logger } = require('@librechat/data-schemas'); -const { MCPOAuthHandler } = require('@librechat/api'); +const { MCPOAuthHandler, GenerationJobManager } = require('@librechat/api'); const { CacheKeys, Constants, Permissions, PermissionTypes } = require('librechat-data-provider'); const D = Constants.mcp_delimiter; const { @@ -837,6 +837,44 @@ describe('User parameter passing tests', () => { expect(mockReinitMCPServer.mock.calls[0][0].user).toBe(mockUser); }); + it('fences resumable tool-loading OAuth events to the owning job epoch', async () => { + const mockUser = { id: 'epoch-loading-user', name: 'Epoch Loading User' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const streamId = 'epoch-loading-stream'; + const jobCreatedAt = 1234; + const flowManager = { + getFlowState: jest.fn().mockResolvedValue(null), + createFlowWithHandler: jest.fn(async (_flowId, _type, handler) => handler()), + failFlow: jest.fn(), + }; + mockGetFlowStateManager.mockReturnValue(flowManager); + mockReinitMCPServer.mockImplementation(async ({ oauthStart }) => { + await oauthStart('https://auth.example.com/loading'); + return { tools: [], availableTools: {} }; + }); + + await createMCPTools({ + res: mockRes, + user: mockUser, + serverName: 'epoch-loading-server', + provider: 'openai', + userMCPAuthMap: {}, + config: { type: 'stdio' }, + streamId, + jobCreatedAt, + }); + + expect(GenerationJobManager.emitChunk).toHaveBeenCalledTimes(2); + expect(GenerationJobManager.emitChunk.mock.calls.map(([, event]) => event.event)).toEqual([ + 'on_run_step', + 'on_run_step_delta', + ]); + for (const [emittedStreamId, , options] of GenerationJobManager.emitChunk.mock.calls) { + expect(emittedStreamId).toBe(streamId); + expect(options).toEqual({ expectedCreatedAt: jobCreatedAt }); + } + }); + it('should fail tenant-scoped OAuth flows when tool loading is aborted', async () => { const mockUser = { id: 'tenant-user', name: 'Tenant User' }; const mockRes = { write: jest.fn(), flush: jest.fn() }; @@ -1054,6 +1092,76 @@ describe('User parameter passing tests', () => { expect(mockGetMCPManager).not.toHaveBeenCalled(); }); + it('fences resumable tool-call OAuth events to the owning job epoch', async () => { + const mockUser = { id: 'epoch-tool-user', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const streamId = 'epoch-tool-stream'; + const jobCreatedAt = 5678; + const { getRoleByName } = require('~/models'); + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + }, + }, + }); + const flowManager = { + getFlowState: jest.fn().mockResolvedValue(null), + createFlowWithHandler: jest.fn(async (_flowId, _type, handler) => handler()), + failFlow: jest.fn(), + }; + mockGetFlowStateManager.mockReturnValue(flowManager); + mockGetMCPManager.mockReturnValue({ + callTool: jest.fn(async ({ oauthStart, oauthEnd }) => { + await oauthStart('https://auth.example.com/tool-call'); + await oauthEnd(); + return ['ok', null]; + }), + }); + + const mcpTool = await createMCPTool({ + res: mockRes, + user: mockUser, + toolKey: `test-tool${D}epoch-tool-server`, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: { + [`test-tool${D}epoch-tool-server`]: { + function: { + description: 'Epoch-fenced tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + streamId, + jobCreatedAt, + }); + + await mcpTool.invoke( + {}, + { + configurable: { user: mockUser }, + metadata: { + provider: 'openai', + thread_id: 'thread-epoch', + run_id: 'run-epoch', + }, + toolCall: { + id: 'tool-call-epoch', + stepId: 'step-epoch', + name: 'test-tool', + type: 'tool_call', + }, + }, + ); + + expect(GenerationJobManager.emitChunk).toHaveBeenCalledTimes(2); + for (const [emittedStreamId, , options] of GenerationJobManager.emitChunk.mock.calls) { + expect(emittedStreamId).toBe(streamId); + expect(options).toEqual({ expectedCreatedAt: jobCreatedAt }); + } + }); + it('should reuse request-scoped MCP permission checks across tool executions', async () => { const mockUser = { id: 'mcp-allowed-user', role: 'USER' }; const mockReq = { user: mockUser }; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index be67b60fa7..ec28d15b55 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -532,6 +532,7 @@ const isBuiltInTool = (toolName) => * @param {ServerResponse} [params.res] - The response object for SSE events * @param {Object} params.agent - The agent configuration * @param {string|null} [params.streamId] - Stream ID for resumable mode + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events * @returns {Promise<{ * toolDefinitions?: import('@librechat/api').LCTool[]; * toolRegistry?: Map; @@ -540,7 +541,14 @@ const isBuiltInTool = (toolName) => * hasDeferredTools?: boolean; * }>} */ -async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, tool_resources }) { +async function loadToolDefinitionsWrapper({ + req, + res, + agent, + streamId = null, + jobCreatedAt, + tool_resources, +}) { if (!agent.tools || agent.tools.length === 0) { return { toolDefinitions: [] }; } @@ -653,8 +661,12 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to }); if (streamId) { - await GenerationJobManager.emitChunk(streamId, runStepEvent); - await GenerationJobManager.emitChunk(streamId, runStepDeltaEvent); + await GenerationJobManager.emitChunk(streamId, runStepEvent, { + expectedCreatedAt: jobCreatedAt, + }); + await GenerationJobManager.emitChunk(streamId, runStepDeltaEvent, { + expectedCreatedAt: jobCreatedAt, + }); } else if (res && !res.writableEnded) { sendEvent(res, runStepEvent); sendEvent(res, runStepDeltaEvent); @@ -683,7 +695,9 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to }); if (streamId) { - await GenerationJobManager.emitChunk(streamId, runStepCompletedEvent); + await GenerationJobManager.emitChunk(streamId, runStepCompletedEvent, { + expectedCreatedAt: jobCreatedAt, + }); } else if (res && !res.writableEnded) { sendEvent(res, runStepCompletedEvent); } else { @@ -1074,6 +1088,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to * @param {Object} [params.tool_resources] - Tool resources * @param {string} [params.openAIApiKey] - OpenAI API key * @param {string|null} [params.streamId] - Stream ID for resumable mode + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events * @param {boolean} [params.definitionsOnly=true] - When true, returns only serializable * tool definitions without creating full tool instances. Use for event-driven mode * where tools are loaded on-demand during execution. @@ -1086,10 +1101,18 @@ async function loadAgentTools({ tool_resources, openAIApiKey, streamId = null, + jobCreatedAt, definitionsOnly = true, }) { if (definitionsOnly) { - return loadToolDefinitionsWrapper({ req, res, agent, streamId, tool_resources }); + return loadToolDefinitionsWrapper({ + req, + res, + agent, + streamId, + jobCreatedAt, + tool_resources, + }); } if (!agent.tools || agent.tools.length === 0) { @@ -1155,7 +1178,7 @@ async function loadAgentTools({ /** @type {ReturnType} */ let webSearchCallbacks; if (includesWebSearch) { - webSearchCallbacks = createOnSearchResults(res, streamId); + webSearchCallbacks = createOnSearchResults(res, streamId, jobCreatedAt); } /** @type {Record>} */ @@ -1178,6 +1201,7 @@ async function loadAgentTools({ options: { req, res, + jobCreatedAt, openAIApiKey, tool_resources, processFileURL, @@ -1387,6 +1411,7 @@ async function loadAgentTools({ name: toolName, description: functionSignature.description, streamId, + jobCreatedAt, useSSRFProtection: !Array.isArray(_allowedDomains) || _allowedDomains.length === 0, allowedAddresses: _allowedAddresses, }); @@ -1440,6 +1465,7 @@ async function loadAgentTools({ * @param {Record>} [params.userMCPAuthMap] - User MCP auth map * @param {Object} [params.tool_resources] - Tool resources * @param {string|null} [params.streamId] - Stream ID for web search callbacks + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events * @param {boolean} [params.actionsEnabled] - Whether the actions capability is enabled * @returns {Promise<{ loadedTools: Array, configurable: Object }>} */ @@ -1456,6 +1482,7 @@ async function loadToolsForExecution({ userMCPAuthMap, tool_resources, streamId = null, + jobCreatedAt, actionsEnabled, }) { const appConfig = req.config; @@ -1611,7 +1638,9 @@ async function loadToolsForExecution({ if (regularToolNames.length > 0) { const includesWebSearch = regularToolNames.includes(Tools.web_search); - const webSearchCallbacks = includesWebSearch ? createOnSearchResults(res, streamId) : undefined; + const webSearchCallbacks = includesWebSearch + ? createOnSearchResults(res, streamId, jobCreatedAt) + : undefined; const { loadedTools } = await loadTools({ agent, @@ -1623,6 +1652,7 @@ async function loadToolsForExecution({ options: { req, res, + jobCreatedAt, tool_resources, processFileURL, uploadImageBuffer, @@ -1648,6 +1678,7 @@ async function loadToolsForExecution({ agent, appConfig, streamId, + jobCreatedAt, actionToolNames, }); allLoadedTools.push(...actionTools); @@ -1686,6 +1717,7 @@ async function loadToolsForExecution({ * @param {Object} params.agent - The agent object * @param {Object} params.appConfig - App configuration * @param {string|null} params.streamId - Stream ID + * @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events * @param {string[]} params.actionToolNames - Action tool names to load * @returns {Promise} Loaded action tools */ @@ -1695,6 +1727,7 @@ async function loadActionToolsForExecution({ agent, appConfig, streamId, + jobCreatedAt, actionToolNames, }) { const loadedActionTools = []; @@ -1787,6 +1820,7 @@ async function loadActionToolsForExecution({ res, action, streamId, + jobCreatedAt, zodSchema, encrypted, requestBuilder, diff --git a/api/server/services/Tools/search.js b/api/server/services/Tools/search.js index c4cdfc752f..c6e031e9ee 100644 --- a/api/server/services/Tools/search.js +++ b/api/server/services/Tools/search.js @@ -8,10 +8,15 @@ const { GenerationJobManager } = require('@librechat/api'); * @param {import('http').ServerResponse} res - The server response object * @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode * @param {Object} attachment - The attachment data + * @param {number} [jobCreatedAt] - The generation epoch that owns the attachment */ -function writeAttachment(res, streamId, attachment) { +function writeAttachment(res, streamId, attachment, jobCreatedAt) { if (streamId) { - GenerationJobManager.emitChunk(streamId, { event: 'attachment', data: attachment }); + GenerationJobManager.emitChunk( + streamId, + { event: 'attachment', data: attachment }, + { expectedCreatedAt: jobCreatedAt }, + ); } else { res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`); } @@ -21,9 +26,10 @@ function writeAttachment(res, streamId, attachment) { * Creates a function to handle search results and stream them as attachments * @param {import('http').ServerResponse} res - The HTTP server response object * @param {string | null} [streamId] - The stream ID for resumable mode, or null for standard mode + * @param {number} [jobCreatedAt] - The generation epoch that owns emitted attachments * @returns {{ onSearchResults: function(SearchResult, GraphRunnableConfig): void; onGetHighlights: function(string): void}} - Function that takes search results and returns or streams an attachment */ -function createOnSearchResults(res, streamId = null) { +function createOnSearchResults(res, streamId = null, jobCreatedAt) { const context = { sourceMap: new Map(), searchResultData: undefined, @@ -86,7 +92,7 @@ function createOnSearchResults(res, streamId = null) { if (!res.headersSent) { return attachment; } - writeAttachment(res, streamId, attachment); + writeAttachment(res, streamId, attachment, jobCreatedAt); } /** @@ -108,7 +114,7 @@ function createOnSearchResults(res, streamId = null) { } const attachment = buildAttachment(context); - writeAttachment(res, streamId, attachment); + writeAttachment(res, streamId, attachment, jobCreatedAt); } return { diff --git a/api/server/services/Tools/search.spec.js b/api/server/services/Tools/search.spec.js new file mode 100644 index 0000000000..8925d84062 --- /dev/null +++ b/api/server/services/Tools/search.spec.js @@ -0,0 +1,63 @@ +const mockEmitChunk = jest.fn(); + +jest.mock('nanoid', () => ({ + nanoid: jest.fn(() => 'search-attachment'), +})); + +jest.mock('@librechat/api', () => ({ + GenerationJobManager: { + emitChunk: (...args) => mockEmitChunk(...args), + }, +})); + +const { createOnSearchResults } = require('./search'); + +describe('createOnSearchResults', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fences resumable search attachments to the owning generation', () => { + const callbacks = createOnSearchResults({ headersSent: true }, 'conversation-1', 1234); + const runnableConfig = { + metadata: { + user_id: 'user-1', + thread_id: 'conversation-1', + run_id: 'response-1', + }, + toolCall: { + id: 'tool-call-1', + name: 'web_search', + turn: 0, + }, + }; + + callbacks.onSearchResults( + { + success: true, + data: { + organic: [{ link: 'https://example.com' }], + topStories: [], + }, + }, + runnableConfig, + ); + callbacks.onGetHighlights('https://example.com'); + + expect(mockEmitChunk).toHaveBeenCalledTimes(2); + for (const call of mockEmitChunk.mock.calls) { + expect(call).toEqual([ + 'conversation-1', + { + event: 'attachment', + data: expect.objectContaining({ + messageId: 'response-1', + toolCallId: 'tool-call-1', + conversationId: 'conversation-1', + }), + }, + { expectedCreatedAt: 1234 }, + ]); + } + }); +}); diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index db8c2c8cb1..6e902de5cd 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -101,6 +101,7 @@ const { processRequiredActions, resolveAgentCapabilities, } = require('../ToolService'); +const { createOnSearchResults } = require('~/server/services/Tools/search'); const { reinitMCPServer } = require('~/server/services/Tools/mcp'); const { PENDING_STALE_MS } = require('@librechat/api'); @@ -425,6 +426,66 @@ describe('ToolService - Action Capability Gating', () => { ]); }); + it('fences resumable MCP OAuth definition events to the owning job epoch', async () => { + const req = createMockReq([AgentCapabilities.tools]); + const res = { writableEnded: false }; + const serverName = 'Epoch-Server'; + const streamId = 'stream-epoch'; + const jobCreatedAt = 1234; + const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig([AgentCapabilities.tools])); + mockResolveConfigServers.mockResolvedValue({ + [serverName]: { + type: 'streamable-http', + url: `https://mcp.example.com/${serverName}`, + requiresOAuth: true, + }, + }); + mockLoadToolDefinitions + .mockImplementationOnce(async (_args, deps) => { + await deps.getOrFetchMCPServerTools(req.user.id, serverName); + return { + toolDefinitions: [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }) + .mockResolvedValue({ + toolDefinitions: [mcpTool], + toolRegistry: new Map(), + hasDeferredTools: false, + }); + reinitMCPServer.mockImplementation(async ({ returnOnOAuth, oauthStart, oauthEnd }) => { + await oauthStart(`https://auth.example.com/${serverName}`); + if (returnOnOAuth === false) { + await oauthEnd(); + return { availableTools: { [mcpTool]: {} } }; + } + return { availableTools: null }; + }); + + await loadAgentTools({ + req, + res, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + streamId, + jobCreatedAt, + }); + + expect(mockSendEvent).not.toHaveBeenCalled(); + expect(mockEmitChunk).toHaveBeenCalledTimes(3); + expect(mockEmitChunk.mock.calls.map(([, event]) => event.event)).toEqual([ + 'on_run_step', + 'on_run_step_delta', + 'on_run_step_completed', + ]); + for (const [emittedStreamId, , options] of mockEmitChunk.mock.calls) { + expect(emittedStreamId).toBe(streamId); + expect(options).toEqual({ expectedCreatedAt: jobCreatedAt }); + } + }); + it('should not expose cached MCP tool definitions when the registry lookup fails', async () => { const serverName = 'private-server'; const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; @@ -968,6 +1029,24 @@ describe('ToolService - Action Capability Gating', () => { const actionToolName = `get_weather${actionDelimiter}api_example_com`; const regularTool = 'calculator'; + it('threads the owning job epoch into web-search attachment callbacks', async () => { + const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search]; + const req = createMockReq(capabilities); + const res = {}; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + + await loadAgentTools({ + req, + res, + streamId: 'conversation-1', + jobCreatedAt: 1234, + agent: { id: 'agent_123', tools: [Tools.web_search] }, + definitionsOnly: false, + }); + + expect(createOnSearchResults).toHaveBeenCalledWith(res, 'conversation-1', 1234); + }); + it('should not load action sets when actions capability is disabled', async () => { const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search]; const req = createMockReq(capabilities); @@ -1003,6 +1082,25 @@ describe('ToolService - Action Capability Gating', () => { const actionToolName = `get_weather${actionDelimiter}api_example_com`; const regularTool = Tools.web_search; + it('threads the owning job epoch into web-search attachment callbacks', async () => { + const capabilities = [AgentCapabilities.tools, AgentCapabilities.web_search]; + const req = createMockReq(capabilities); + const res = {}; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + + await loadToolsForExecution({ + req, + res, + streamId: 'conversation-1', + jobCreatedAt: 1234, + agent: { id: 'agent_123', tools: [Tools.web_search] }, + toolNames: [Tools.web_search], + actionsEnabled: false, + }); + + expect(createOnSearchResults).toHaveBeenCalledWith(res, 'conversation-1', 1234); + }); + it('does not load code execution tools that were not registered for the agent', async () => { const capabilities = [ AgentCapabilities.tools, diff --git a/api/typedefs.js b/api/typedefs.js index 6ec36b3573..92839f48d4 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -933,6 +933,7 @@ * signal?: AbortSignal, * memory?: ConversationSummaryBufferMemory, * tool_resources?: AgentToolResources, + * jobCreatedAt?: number, * web_search?: ReturnType, * }} LoadToolOptions * @memberof typedefs diff --git a/client/package.json b/client/package.json index 6807682a54..4ffd3c3bf5 100644 --- a/client/package.json +++ b/client/package.json @@ -159,7 +159,7 @@ "jest-environment-jsdom": "^30.2.0", "jest-file-loader": "^1.0.3", "jest-junit": "^17.0.0", - "postcss": "^8.4.31", + "postcss": "^8.5.18", "postcss-preset-env": "^11.2.0", "tailwindcss": "^3.4.1", "typescript": "^5.9.3", diff --git a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx index 7b5bcf9477..8fa66614da 100644 --- a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx +++ b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx @@ -281,6 +281,9 @@ export function useResumeSubmit() { const approvalMutation = useSubmitToolApprovalMutation(); const askMutation = useSubmitAskAnswerMutation(); const { getDecisions, isReady, setStatus } = useApprovalContext(); + /** React state cannot lock a second click in the same browser task. Keep a + * synchronous action-id guard alongside the rendered submission status. */ + const submittingToolActionIdsRef = useRef(new Set()); /** Ask status lives in Recoil so it works from the composer (outside the * provider); tool-approval status stays on the context. */ const { setAskStatus } = useAskSubmitStatus(); @@ -311,12 +314,23 @@ export function useResumeSubmit() { if (!fields || decisions.length === 0 || !isReady(actionId)) { return; } + if (submittingToolActionIdsRef.current.has(actionId)) { + return; + } + submittingToolActionIdsRef.current.add(actionId); setStatus(actionId, 'submitting'); approvalMutation.mutate( { ...fields, actionId, decisions }, { onSuccess: () => setStatus(actionId, 'submitted'), - onError: (error) => setStatus(actionId, isExpiredError(error) ? 'expired' : 'error'), + onError: (error) => { + const expired = isExpiredError(error); + if (!expired) { + // Network/validation failures are retryable; a 409 is terminal. + submittingToolActionIdsRef.current.delete(actionId); + } + setStatus(actionId, expired ? 'expired' : 'error'); + }, }, ); }, diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 301d35532e..ef286c44ee 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -327,6 +327,7 @@ const Part = memo(function Part({ diff --git a/client/src/components/Chat/Messages/Content/ToolApproval.tsx b/client/src/components/Chat/Messages/Content/ToolApproval.tsx index 1068eadd1e..31ee82f654 100644 --- a/client/src/components/Chat/Messages/Content/ToolApproval.tsx +++ b/client/src/components/Chat/Messages/Content/ToolApproval.tsx @@ -174,7 +174,11 @@ export default function ToolApproval({ } return ( -
+
{description != null && description.length > 0 && (

{description}

)} diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 2efde33a20..4eb0a3d0f9 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -22,6 +22,7 @@ export default function ToolCall({ initialProgress = 0.1, isLast = false, isSubmitting, + toolCallId, name, args: _args = '', output, @@ -33,6 +34,7 @@ export default function ToolCall({ initialProgress: number; isLast?: boolean; isSubmitting: boolean; + toolCallId?: string; name: string; args: string | Record; output?: string | null; @@ -214,7 +216,11 @@ export default function ToolCall({ return getFinishedText(); })()} -
+
-
+
{hasInfo && (
diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx index 955f9b2b47..1d86ac356d 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -24,6 +24,27 @@ interface ToolMeta { hasOutput: boolean; } +type ToolCallWithNestedContent = Agents.ToolCall & { + subagent_content?: TMessageContentParts[]; +}; + +function hasPendingApprovalInPart(part: TMessageContentParts): boolean { + if (part.type !== ContentTypes.TOOL_CALL) { + return false; + } + const toolCall = part[ContentTypes.TOOL_CALL] as ToolCallWithNestedContent | undefined; + if (!toolCall) { + return false; + } + if (toolCall.approval != null && (toolCall.output?.length ?? 0) === 0) { + return true; + } + return ( + Array.isArray(toolCall.subagent_content) && + toolCall.subagent_content.some(hasPendingApprovalInPart) + ); +} + function getToolMeta(part: TMessageContentParts): ToolMeta | null { if (part.type !== ContentTypes.TOOL_CALL) { return null; @@ -107,9 +128,14 @@ export default function ToolCallGroup({ const mcpIconMap = useMCPIconMap(); const rootRef = useRef(null); const cancelLayoutReconcileRef = useRef<(() => void) | null>(null); + const retainedForPendingApprovalRef = useRef(false); const count = parts.length; const toolMetadata = useMemo(() => parts.map((p) => getToolMeta(p.part)), [parts]); + const hasPendingApproval = useMemo( + () => parts.some(({ part }) => hasPendingApprovalInPart(part)), + [parts], + ); const allCompleted = useMemo( () => toolMetadata.every((m) => m?.hasOutput === true), [toolMetadata], @@ -226,12 +252,34 @@ export default function ToolCallGroup({ if (isExpanded) { return; } + if (hasPendingApproval) { + // Approval controls own unsent local form state. Keep unresolved cards + // mounted (the collapsed panel is inert/hidden) so collapsing a batch + // cannot erase decisions the reviewer already made. + retainedForPendingApprovalRef.current = true; + return; + } + retainedForPendingApprovalRef.current = false; setShouldRenderBody(false); notifyLayoutChange(); }, - [isExpanded, notifyLayoutChange], + [hasPendingApproval, isExpanded, notifyLayoutChange], ); + useEffect(() => { + if (isExpanded) { + retainedForPendingApprovalRef.current = false; + return; + } + if (!hasPendingApproval && retainedForPendingApprovalRef.current) { + // A completed collapse transition retained this body only to preserve + // approval form state. Release it once the last approval resolves. + retainedForPendingApprovalRef.current = false; + setShouldRenderBody(false); + notifyLayoutChange(); + } + }, [hasPendingApproval, isExpanded, notifyLayoutChange]); + /** Category-aware header verb: subagents and questions read as their own * category (with tense), everything else is the generic "Used N tools". */ const resolveGroupLabel = (): string => { @@ -310,7 +358,12 @@ export default function ToolCallGroup({ aria-hidden="true" /> -
+
{shouldRenderBody && (
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ApprovalContext.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ApprovalContext.test.tsx new file mode 100644 index 0000000000..2ce2ae93bd --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/ApprovalContext.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { act, renderHook } from '@testing-library/react'; +import ApprovalProvider, { useApprovalContext, useResumeSubmit } from '../ApprovalContext'; +import { ChatContext } from '~/Providers/ChatContext'; + +const mockApprovalMutate = jest.fn(); +const mockAskMutate = jest.fn(); + +jest.mock('~/data-provider', () => ({ + useSubmitToolApprovalMutation: () => ({ mutate: mockApprovalMutate }), + useSubmitAskAnswerMutation: () => ({ mutate: mockAskMutate }), +})); + +jest.mock('~/store/agents', () => ({ + useGetEphemeralAgent: () => () => undefined, +})); + +const chatContextValue = { + conversation: { + conversationId: 'conversation-1', + endpoint: 'agents', + agent_id: 'agent-1', + }, +} as unknown as React.ContextType; + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +describe('useResumeSubmit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('synchronously deduplicates tool approval submissions and unlocks a retryable error', () => { + const { result } = renderHook( + () => ({ + approval: useApprovalContext(), + resume: useResumeSubmit(), + }), + { wrapper }, + ); + + act(() => { + result.current.approval.registerToolCall('action-1', 'call-1'); + result.current.approval.setDecision('action-1', 'call-1', { + tool_call_id: 'call-1', + decision: 'approve', + }); + }); + + act(() => { + result.current.resume.submitToolApproval('action-1'); + result.current.resume.submitToolApproval('action-1'); + }); + expect(mockApprovalMutate).toHaveBeenCalledTimes(1); + + const firstOptions = mockApprovalMutate.mock.calls[0][1] as { + onError: (error: unknown) => void; + }; + act(() => firstOptions.onError(new Error('temporary failure'))); + + act(() => { + result.current.resume.submitToolApproval('action-1'); + result.current.resume.submitToolApproval('action-1'); + }); + expect(mockApprovalMutate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx index 3abff264a0..10d61e4482 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx @@ -83,6 +83,36 @@ const makePart = ( }, }) as unknown as TMessageContentParts; +const makeApprovalPart = (id: string, output = ''): TMessageContentParts => + ({ + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { + id, + name: 'approval_probe', + args: {}, + output, + approval: { + actionId: 'action-1', + allowed_decisions: ['approve', 'reject'], + }, + }, + }) as unknown as TMessageContentParts; + +const makeSubagentPart = ( + id: string, + subagentContent: TMessageContentParts[], +): TMessageContentParts => + ({ + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { + id, + name: Constants.SUBAGENT, + args: {}, + output: '', + subagent_content: subagentContent, + }, + }) as unknown as TMessageContentParts; + const imageAttachment: TAttachment = { filename: 'foo.png', filepath: '/files/foo.png', @@ -211,6 +241,169 @@ describe('ToolCallGroup image hoisting', () => { expect(screen.queryByTestId('inner-0')).not.toBeInTheDocument(); }); + it('keeps unresolved approval bodies mounted while the group is collapsed', () => { + const approvalParts = [ + { part: makeApprovalPart('t1'), idx: 0 }, + { part: makeApprovalPart('t2'), idx: 1 }, + ]; + renderGroup({ + ...baseProps, + parts: approvalParts, + renderPart: (_p: TMessageContentParts, idx: number) => ( +
+ {'approval'} +
+ ), + }); + + const button = screen.getByRole('button', { name: 'Used 2 tools' }); + const collapsible = button.nextElementSibling as HTMLElement; + expect(screen.getByTestId('approval-0')).toBeInTheDocument(); + + fireEvent.click(button); + fireEvent.transitionEnd(collapsible); + + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByTestId('approval-0')).toBeInTheDocument(); + expect(screen.getByTestId('approval-1')).toBeInTheDocument(); + }); + + it('keeps deeply nested unresolved approval bodies mounted while the group is collapsed', () => { + const nestedApprovalParts = [ + { + part: makeSubagentPart('parent', [ + makeSubagentPart('child', [makeApprovalPart('grandchild')]), + ]), + idx: 0, + }, + { part: makePart('sibling'), idx: 1 }, + ]; + renderGroup({ + ...baseProps, + parts: nestedApprovalParts, + renderPart: (_p: TMessageContentParts, idx: number) => ( +
+ {'nested'} +
+ ), + }); + + const button = screen.getByRole('button', { name: 'Used 2 tools' }); + const collapsible = button.nextElementSibling as HTMLElement; + fireEvent.click(button); + fireEvent.transitionEnd(collapsible); + + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByTestId('nested-0')).toBeInTheDocument(); + expect(screen.getByTestId('nested-1')).toBeInTheDocument(); + }); + + it('does not retain a collapsed group for an already resolved nested approval', () => { + const nestedApprovalParts = [ + { + part: makeSubagentPart('parent', [ + makeSubagentPart('child', [makeApprovalPart('grandchild', 'done')]), + ]), + idx: 0, + }, + { part: makePart('sibling'), idx: 1 }, + ]; + renderGroup({ + ...baseProps, + parts: nestedApprovalParts, + renderPart: (_p: TMessageContentParts, idx: number) => ( +
+ {'nested'} +
+ ), + }); + + const button = screen.getByRole('button', { name: 'Used 2 tools' }); + const collapsible = button.nextElementSibling as HTMLElement; + fireEvent.click(button); + fireEvent.transitionEnd(collapsible); + + expect(screen.queryByTestId('resolved-nested-0')).not.toBeInTheDocument(); + expect(screen.queryByTestId('resolved-nested-1')).not.toBeInTheDocument(); + }); + + it('unmounts retained approval bodies after every approval in a collapsed group resolves', async () => { + const renderPart = (_p: TMessageContentParts, idx: number) => ( +
+ {'approval'} +
+ ); + const propsFor = ( + firstOutput = '', + secondOutput = '', + ): React.ComponentProps => ({ + ...baseProps, + parts: [ + { part: makeApprovalPart('t1', firstOutput), idx: 0 }, + { part: makeApprovalPart('t2', secondOutput), idx: 1 }, + ], + renderPart, + }); + const { rerender } = renderGroup(propsFor()); + + const button = screen.getByRole('button', { name: 'Used 2 tools' }); + const collapsible = button.nextElementSibling as HTMLElement; + fireEvent.click(button); + fireEvent.transitionEnd(collapsible); + expect(screen.getByTestId('retained-0')).toBeInTheDocument(); + + rerender( + + + , + ); + expect(screen.getByTestId('retained-0')).toBeInTheDocument(); + expect(screen.getByTestId('retained-1')).toBeInTheDocument(); + + rerender( + + + , + ); + await waitFor(() => { + expect(screen.queryByTestId('retained-0')).not.toBeInTheDocument(); + expect(screen.queryByTestId('retained-1')).not.toBeInTheDocument(); + }); + }); + + it('waits for an active collapse transition before unmounting resolved approval bodies', () => { + const renderPart = (_p: TMessageContentParts, idx: number) => ( +
+ {'approval'} +
+ ); + const propsFor = (output = ''): React.ComponentProps => ({ + ...baseProps, + parts: [ + { part: makeApprovalPart('t1', output), idx: 0 }, + { part: makeApprovalPart('t2', output), idx: 1 }, + ], + renderPart, + }); + const { rerender } = renderGroup(propsFor()); + + const button = screen.getByRole('button', { name: 'Used 2 tools' }); + const collapsible = button.nextElementSibling as HTMLElement; + fireEvent.click(button); + + rerender( + + + , + ); + expect(screen.getByTestId('transitioning-0')).toBeInTheDocument(); + expect(screen.getByTestId('transitioning-1')).toBeInTheDocument(); + + fireEvent.transitionEnd(collapsible); + expect(screen.queryByTestId('transitioning-0')).not.toBeInTheDocument(); + expect(screen.queryByTestId('transitioning-1')).not.toBeInTheDocument(); + }); + it('reconciles layout after the group collapses from an expanded state', async () => { renderGroup(baseProps); diff --git a/client/src/components/SidePanel/Agents/Advanced/AgentHandoffs.tsx b/client/src/components/SidePanel/Agents/Advanced/AgentHandoffs.tsx index 0a3c798b48..2cc2484de1 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AgentHandoffs.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AgentHandoffs.tsx @@ -36,25 +36,50 @@ const AgentHandoffs: React.FC = ({ field, currentAgentId }) const edges = useMemo(() => field.value ?? [], [field.value]); const { options, getAgent } = useSelectableAgents({ currentAgentId }); + const selectedAgentIds = useMemo( + () => new Set(edges.map((edge) => getTargetAgentId(edge.to))), + [edges], + ); + const addAgentOptions = useMemo( + () => + options.filter( + (option) => typeof option.value === 'string' && !selectedAgentIds.has(option.value), + ), + [options, selectedAgentIds], + ); useEffect(() => { - if (newAgentId && edges.length < MAX_HANDOFFS) { + if (!newAgentId) { + return; + } + + if (edges.length < MAX_HANDOFFS && !selectedAgentIds.has(newAgentId)) { const newEdge: GraphEdge = { from: currentAgentId, to: newAgentId, edgeType: 'handoff' }; field.onChange([...edges, newEdge]); - setNewAgentId(''); } - }, [newAgentId, edges, field, currentAgentId]); + setNewAgentId(''); + }, [newAgentId, edges, field, currentAgentId, selectedAgentIds]); const removeHandoffAt = (index: number) => { field.onChange(edges.filter((_, i) => i !== index)); - setExpandedIndices((prev) => { - const next = new Set(prev); - next.delete(index); - return next; - }); + setExpandedIndices( + (prev) => + new Set( + Array.from(prev) + .filter((expandedIndex) => expandedIndex !== index) + .map((expandedIndex) => (expandedIndex > index ? expandedIndex - 1 : expandedIndex)), + ), + ); }; const updateHandoffAt = (index: number, agentId: string) => { + const isAlreadySelected = edges.some( + (edge, edgeIndex) => edgeIndex !== index && getTargetAgentId(edge.to) === agentId, + ); + if (isAlreadySelected) { + return; + } + const updated = [...edges]; updated[index] = { ...updated[index], to: agentId }; field.onChange(updated); @@ -101,6 +126,11 @@ const AgentHandoffs: React.FC = ({ field, currentAgentId }) const targetAgentId = getTargetAgentId(edge.to); const isExpanded = expandedIndices.has(idx); const targetName = getAgent(targetAgentId)?.name ?? localize('com_ui_agent'); + const rowOptions = options.filter( + (option) => + typeof option.value === 'string' && + (option.value === targetAgentId || !selectedAgentIds.has(option.value)), + ); return ( @@ -111,7 +141,7 @@ const AgentHandoffs: React.FC = ({ field, currentAgentId }) removeLabel={localize('com_ui_agent_handoff_remove', { 0: targetName })} > updateHandoffAt(idx, id)} displayValue={getAgent(targetAgentId)?.name ?? ''} @@ -207,7 +237,7 @@ const AgentHandoffs: React.FC = ({ field, currentAgentId }) <> {edges.length > 0 && } -
+
{children != null &&
{children}
} -
+
{info}
diff --git a/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx index 874931679c..0cdcf982dd 100644 --- a/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx +++ b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx @@ -57,6 +57,7 @@ export default function VersionPanel() { artifacts: agentWithVersions.artifacts, capabilities: agentWithVersions.capabilities, tools: agentWithVersions.tools, + edges: agentWithVersions.edges, }; }, [agentWithVersions]); diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx index f8626ed586..8a0c20d421 100644 --- a/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx +++ b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx @@ -10,6 +10,7 @@ const mockAgentData = { instructions: 'Test Instructions', tools: ['tool1', 'tool2'], capabilities: ['capability1', 'capability2'], + edges: [{ from: 'agent-123', to: 'agent-specialist', edgeType: 'handoff' }], }; const mockVersions = [ @@ -235,6 +236,7 @@ describe('VersionPanel', () => { name: 'Test Agent', description: 'Test Description', instructions: 'Test Instructions', + edges: [{ from: 'agent-123', to: 'agent-specialist', edgeType: 'handoff' }], }), versions: expect.arrayContaining([ expect.objectContaining({ name: 'Version 2' }), diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts b/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts index cf42c94712..fe3adc07b9 100644 --- a/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts +++ b/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts @@ -72,6 +72,36 @@ describe('isActiveVersion', () => { expect(isActiveVersion(version, currentAgent, versions)).toBe(false); }); + test('returns false when handoff edges do not match', () => { + const version = createVersion({ + edges: [{ from: 'router', to: 'researcher', edgeType: 'handoff' }], + }); + const currentAgent = createAgentState({ + edges: [{ from: 'router', to: 'writer', edgeType: 'handoff' }], + }); + const versions = [version]; + + expect(isActiveVersion(version, currentAgent, versions)).toBe(false); + }); + + test('returns true when handoff edges match', () => { + const edges = [ + { + from: 'router', + to: 'researcher', + edgeType: 'handoff', + description: 'Delegate research', + prompt: 'Provide the research brief', + promptKey: 'context', + }, + ]; + const version = createVersion({ edges }); + const currentAgent = createAgentState({ edges: edges.map((edge) => ({ ...edge })) }); + const versions = [version]; + + expect(isActiveVersion(version, currentAgent, versions)).toBe(true); + }); + test('matches tools regardless of order', () => { const version = createVersion({ tools: ['tool1', 'tool2'] }); const currentAgent = createAgentState({ tools: ['tool2', 'tool1'] }); @@ -203,6 +233,14 @@ describe('isActiveVersion', () => { expect(isActiveVersion(version, currentAgent, versions)).toBe(true); }); + test('treats missing and empty handoff edges as equivalent', () => { + const version = createVersion({ edges: undefined }); + const currentAgent = createAgentState({ edges: [] }); + const versions = [version]; + + expect(isActiveVersion(version, currentAgent, versions)).toBe(true); + }); + test('handles missing artifacts field', () => { const version = createVersion({ artifacts: undefined }); const currentAgent = createAgentState({ artifacts: undefined }); diff --git a/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts b/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts index e0eb5f66d3..9ef22c685e 100644 --- a/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts +++ b/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts @@ -1,5 +1,10 @@ +import isEqual from 'lodash/isEqual'; +import type { GraphEdge } from 'librechat-data-provider'; import type { AgentState, VersionRecord } from './types'; +const edgesMatch = (versionEdges?: GraphEdge[], currentEdges?: GraphEdge[]): boolean => + isEqual(versionEdges ?? [], currentEdges ?? []); + export const isActiveVersion = ( version: VersionRecord, currentAgent: AgentState, @@ -23,6 +28,7 @@ export const isActiveVersion = ( const matchesDescription = version.description === currentAgent.description; const matchesInstructions = version.instructions === currentAgent.instructions; const matchesArtifacts = version.artifacts === currentAgent.artifacts; + const matchesEdges = edgesMatch(version.edges, currentAgent.edges); const toolsMatch = () => { if (!version.tools && !currentAgent.tools) return true; @@ -53,6 +59,7 @@ export const isActiveVersion = ( matchesDescription && matchesInstructions && matchesArtifacts && + matchesEdges && toolsMatch() && capabilitiesMatch() ); diff --git a/client/src/components/SidePanel/Agents/Version/types.ts b/client/src/components/SidePanel/Agents/Version/types.ts index 210e4d32b5..1e5345c6cf 100644 --- a/client/src/components/SidePanel/Agents/Version/types.ts +++ b/client/src/components/SidePanel/Agents/Version/types.ts @@ -1,3 +1,5 @@ +import type { GraphEdge } from 'librechat-data-provider'; + export type VersionRecord = Record; export type AgentState = { @@ -7,6 +9,7 @@ export type AgentState = { artifacts?: string | null; capabilities?: string[]; tools?: string[]; + edges?: GraphEdge[]; } | null; export type VersionWithId = { @@ -31,5 +34,6 @@ export interface AgentWithVersions { artifacts?: string | null; capabilities?: string[]; tools?: string[]; + edges?: GraphEdge[]; versions?: Array; } diff --git a/client/src/data-provider/Agents/__tests__/mutations.test.ts b/client/src/data-provider/Agents/__tests__/mutations.test.ts new file mode 100644 index 0000000000..ac4bb45a1d --- /dev/null +++ b/client/src/data-provider/Agents/__tests__/mutations.test.ts @@ -0,0 +1,113 @@ +import { createElement } from 'react'; +import { dataService, QueryKeys } from 'librechat-data-provider'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { Agent, GraphEdge } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import { useDeleteAgentMutation } from '../mutations'; + +jest.mock('librechat-data-provider', () => { + const actual = jest.requireActual('librechat-data-provider'); + return { + ...actual, + dataService: { + ...actual.dataService, + deleteAgent: jest.fn(), + }, + }; +}); + +const createAgent = (id: string, edges: GraphEdge[] = []): Agent => ({ + id, + name: id, + description: null, + created_at: 0, + avatar: null, + provider: 'openAI', + model: 'test-model', + model_parameters: { + temperature: null, + maxContextTokens: null, + max_context_tokens: null, + max_output_tokens: null, + top_p: null, + frequency_penalty: null, + presence_penalty: null, + }, + edges, +}); + +const createWrapper = (queryClient: QueryClient) => + function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; + +describe('useDeleteAgentMutation', () => { + it('refreshes only expanded agent caches with edges that reference the deleted agent', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const targetId = 'agent_target'; + const affectedId = 'agent_affected'; + const affectedSourceId = 'agent_affected_source'; + const unrelatedId = 'agent_unrelated'; + const affectedQueryKey = [QueryKeys.agent, affectedId, 'expanded']; + const affectedSourceQueryKey = [QueryKeys.agent, affectedSourceId, 'expanded']; + const unrelatedQueryKey = [QueryKeys.agent, unrelatedId, 'expanded']; + const staleAffectedAgent = createAgent(affectedId, [ + { from: affectedId, to: targetId, edgeType: 'handoff' }, + ]); + const refreshedAffectedAgent = createAgent(affectedId); + const staleAffectedSourceAgent = createAgent(affectedSourceId, [ + { + from: [targetId, 'agent_surviving_source'], + to: affectedSourceId, + edgeType: 'handoff', + }, + ]); + const refreshedAffectedSourceAgent = createAgent(affectedSourceId, [ + { from: 'agent_surviving_source', to: affectedSourceId, edgeType: 'handoff' }, + ]); + const unrelatedAgent = createAgent(unrelatedId, [ + { from: unrelatedId, to: 'agent_other', edgeType: 'handoff' }, + ]); + const affectedFetch = jest + .fn, []>() + .mockResolvedValueOnce(staleAffectedAgent) + .mockResolvedValue(refreshedAffectedAgent); + const affectedSourceFetch = jest + .fn, []>() + .mockResolvedValueOnce(staleAffectedSourceAgent) + .mockResolvedValue(refreshedAffectedSourceAgent); + const unrelatedFetch = jest.fn, []>().mockResolvedValue(unrelatedAgent); + + await queryClient.prefetchQuery(affectedQueryKey, affectedFetch); + await queryClient.prefetchQuery(affectedSourceQueryKey, affectedSourceFetch); + await queryClient.prefetchQuery(unrelatedQueryKey, unrelatedFetch); + queryClient.setQueryData([QueryKeys.agent, targetId], createAgent(targetId)); + queryClient.setQueryData([QueryKeys.agent, targetId, 'expanded'], createAgent(targetId)); + + jest.mocked(dataService.deleteAgent).mockResolvedValue(); + const { result } = renderHook(() => useDeleteAgentMutation(), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + await result.current.mutateAsync({ agent_id: targetId }); + }); + + await waitFor(() => expect(affectedFetch).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(affectedSourceFetch).toHaveBeenCalledTimes(2)); + + expect(queryClient.getQueryData(affectedQueryKey)).toEqual(refreshedAffectedAgent); + expect(queryClient.getQueryData(affectedSourceQueryKey)).toEqual(refreshedAffectedSourceAgent); + expect(unrelatedFetch).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(unrelatedQueryKey)).toEqual(unrelatedAgent); + expect(queryClient.getQueryData([QueryKeys.agent, targetId])).toBeUndefined(); + expect(queryClient.getQueryData([QueryKeys.agent, targetId, 'expanded'])).toBeUndefined(); + }); +}); diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts index 6c36b3c6f9..133d57a59a 100644 --- a/client/src/data-provider/Agents/mutations.ts +++ b/client/src/data-provider/Agents/mutations.ts @@ -11,6 +11,25 @@ export const allAgentViewAndEditQueryKeys: t.AgentListParams[] = [ { requiredPermission: PermissionBits.EDIT }, ]; +const edgeEndpointIncludesAgent = (endpoint: string | string[], agentId: string): boolean => + Array.isArray(endpoint) ? endpoint.includes(agentId) : endpoint === agentId; + +const hasEdgeWithAgent = (data: unknown, agentId: string): boolean => { + if (!data || typeof data !== 'object') { + return false; + } + + const { edges } = data as Partial; + return ( + Array.isArray(edges) && + edges.some( + (edge) => + edgeEndpointIncludesAgent(edge.from, agentId) || + edgeEndpointIncludesAgent(edge.to, agentId), + ) + ); +}; + /** * Create a new agent */ @@ -132,6 +151,15 @@ export const useDeleteAgentMutation = ( queryClient.removeQueries([QueryKeys.agent, variables.agent_id]); queryClient.removeQueries([QueryKeys.agent, variables.agent_id, 'expanded']); + /** Deletion removes the agent from every edge endpoint server-side. Expanded queries + * opt out of refetch-on-mount, so refresh every cached graph known to reference it. */ + queryClient.invalidateQueries({ + queryKey: [QueryKeys.agent], + predicate: (query) => + query.queryKey[2] === 'expanded' && + hasEdgeWithAgent(query.state.data, variables.agent_id), + refetchType: 'all', + }); invalidateAgentMarketplaceQueries(queryClient); return options?.onSuccess?.(_data, variables, data); diff --git a/client/src/utils/approval.spec.ts b/client/src/utils/approval.spec.ts index 0f2de67d69..2a7d5cf9cc 100644 --- a/client/src/utils/approval.spec.ts +++ b/client/src/utils/approval.spec.ts @@ -71,6 +71,37 @@ describe('applyPendingAction — tool_approval', () => { }); }); + it('replaces displayed tool args with the matching action request arguments', () => { + const originalArgs = { query: 'original model args' }; + const rewrittenArgs = { query: 'rewritten by policy hook' }; + const message = msg({ content: [toolCallPart('tc1', { args: originalArgs })] }); + const action = toolApprovalAction({ + payload: { + type: 'tool_approval', + action_requests: [ + { + name: 'search', + arguments: rewrittenArgs, + tool_call_id: 'tc1', + description: 'Review rewritten search', + }, + ], + review_configs: [ + { + action_name: 'search', + tool_call_id: 'tc1', + allowed_decisions: ['approve', 'reject', 'edit', 'respond'], + }, + ], + }, + }); + + const result = applyPendingAction(message, action); + + expect(getToolCall(result.content?.[0] as TMessageContentParts)?.args).toEqual(rewrittenArgs); + expect(getToolCall(message.content?.[0] as TMessageContentParts)?.args).toEqual(originalArgs); + }); + it('leaves a completed tool call (with output) untouched and returns the same message reference', () => { const message = msg({ content: [toolCallPart('tc1', { output: 'already ran' })] }); const result = applyPendingAction(message, toolApprovalAction()); diff --git a/client/src/utils/approval.ts b/client/src/utils/approval.ts index eb99abda06..8a56ec44bc 100644 --- a/client/src/utils/approval.ts +++ b/client/src/utils/approval.ts @@ -95,6 +95,10 @@ function tagApprovalOnPart( const reviewConfig = reviewByToolCallId.get(toolCallId); nextToolCall = { ...nextToolCall, + // A PreToolUse hook may replace the model's original args before asking. + // The interrupt payload is authoritative so the reviewer sees, edits, and + // approves the same arguments the resumed tool will actually execute. + args: request.arguments, approval: { actionId, allowed_decisions: reviewConfig?.allowed_decisions ?? [], diff --git a/config/__tests__/invite-user.spec.js b/config/__tests__/invite-user.spec.js new file mode 100644 index 0000000000..b80e32d31d --- /dev/null +++ b/config/__tests__/invite-user.spec.js @@ -0,0 +1,16 @@ +jest.mock('../connect', () => jest.fn(() => new Promise(() => {}))); + +describe('Invite user CLI', () => { + it('loads its runtime dependencies', () => { + const existingHandlers = new Set(process.listeners('uncaughtException')); + + try { + expect(() => require('../invite-user')).not.toThrow(); + } finally { + process + .listeners('uncaughtException') + .filter((handler) => !existingHandlers.has(handler)) + .forEach((handler) => process.removeListener('uncaughtException', handler)); + } + }); +}); diff --git a/config/invite-user.js b/config/invite-user.js index 80fe0ab23a..7dfa38e454 100644 --- a/config/invite-user.js +++ b/config/invite-user.js @@ -1,10 +1,10 @@ const path = require('path'); const mongoose = require('mongoose'); -const { checkEmailConfig } = require('@librechat/api'); +const { checkEmailConfig, createInvite } = require('@librechat/api'); const { User } = require('@librechat/data-schemas').createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, silentExit } = require('./helpers'); -const { createInvite } = require('~/models/inviteUser'); +const { createToken, findToken } = require('~/models'); const { sendEmail } = require('~/server/utils'); const connect = require('./connect'); @@ -35,6 +35,9 @@ const connect = require('./connect'); if (!email) { email = await askQuestion('Email:'); } + /** `findToken` lowercases its email query, but the Token schema has no setter, so an + * un-normalized address here is written verbatim and can never be looked up again. */ + email = email.trim().toLowerCase(); // Validate the email if (!email.includes('@')) { console.red('Error: Invalid email address!'); @@ -48,7 +51,12 @@ const connect = require('./connect'); silentExit(1); } - const token = await createInvite(email); + const token = await createInvite(email, { createToken, findToken }); + if (typeof token !== 'string') { + console.red('Error: Failed to create the invite token!'); + silentExit(1); + } + const inviteLink = `${process.env.DOMAIN_CLIENT}/register?token=${token}`; const appName = process.env.APP_TITLE || 'LibreChat'; diff --git a/e2e/config/librechat.e2e.yaml b/e2e/config/librechat.e2e.yaml index cf22ac4c8f..71dbb90083 100644 --- a/e2e/config/librechat.e2e.yaml +++ b/e2e/config/librechat.e2e.yaml @@ -67,6 +67,18 @@ endpoints: - chain - ocr - run_in_background + # Keep the shared mock profile non-interactive except for the dedicated + # approval probe. This exercises real HITL pause/resume without wedging the + # existing file-authoring, steering, background-tool, or MCP specs. + toolApproval: + enabled: true + mode: bypass + ask: + - approval_probe_mcp_e2e-memory + reason: E2E approval required before running {tool}. + hooks: + - module: e2e/setup/tool-approval-hook.js + matcher: ^approval_probe_mcp_e2e-memory$ custom: - name: 'Mock Provider A' apiKey: 'e2e-mock-key-a' diff --git a/e2e/setup/fake-mcp-server.js b/e2e/setup/fake-mcp-server.js index 182d46bd80..03c2602147 100644 --- a/e2e/setup/fake-mcp-server.js +++ b/e2e/setup/fake-mcp-server.js @@ -1,9 +1,19 @@ #!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); const z = require('zod/v4'); +const APPROVAL_AUDIT_DIR = path.join('/tmp', 'librechat-e2e-approval-audit'); + +function recordApprovalInvocation(value) { + fs.mkdirSync(APPROVAL_AUDIT_DIR, { recursive: true }); + const filename = Buffer.from(value).toString('base64url'); + fs.appendFileSync(path.join(APPROVAL_AUDIT_DIR, filename), `${value}\n`); +} + const server = new McpServer({ name: 'e2e-memory', version: '1.0.0', @@ -66,6 +76,29 @@ server.registerTool( }, ); +server.registerTool( + 'approval_probe', + { + description: + 'Echoes reviewed input so LibreChat mock end-to-end tests can verify tool approval decisions.', + inputSchema: { + value: z.string(), + review: z.string().optional(), + }, + }, + async ({ value }) => { + recordApprovalInvocation(value); + return { + content: [ + { + type: 'text', + text: `E2E approval probe executed: ${value}`, + }, + ], + }; + }, +); + async function main() { await server.connect(new StdioServerTransport()); } diff --git a/e2e/setup/fake-model.js b/e2e/setup/fake-model.js index d432e687d0..32007dfd5c 100644 --- a/e2e/setup/fake-model.js +++ b/e2e/setup/fake-model.js @@ -34,6 +34,12 @@ const FORCED_ERROR_MARKER = 'E2E_FORCED_ERROR:'; const MARKDOWN_REPLY_MARKER = 'E2E_MARKDOWN_REPLY'; const BACKGROUND_DISPATCH_MARKER = 'E2E_BACKGROUND_DISPATCH:'; const BACKGROUND_COLLECT_MARKER = 'E2E_BACKGROUND_COLLECT:'; +const TOOL_APPROVAL_MARKER = 'E2E_TOOL_APPROVAL:'; +const TOOL_APPROVAL_BATCH_MARKER = 'E2E_TOOL_APPROVAL_BATCH:'; +const TOOL_APPROVAL_RESTRICTED_MARKER = 'E2E_TOOL_APPROVAL_RESTRICTED:'; +const TOOL_APPROVAL_REWRITE_MARKER = 'E2E_TOOL_APPROVAL_REWRITE:'; +const HANDOFF_MARKER = 'E2E_HANDOFF:'; +const HANDOFF_TOOL_PREFIX = 'lc_transfer_to_'; const CREATE_FILE_AUTHORING_FINAL_TEXT = 'E2E file authoring complete'; const EDIT_FILE_AUTHORING_FINAL_TEXT = 'E2E file edit complete'; const SKILL_ASSERTION_FINAL_TEXT = 'E2E skill assertion passed'; @@ -56,6 +62,8 @@ const CREATE_SKILL_TOOL_CALL_ID = 'call_e2e_create_skill'; const EDIT_SKILL_TOOL_CALL_ID = 'call_e2e_edit_skill'; const BACKGROUND_TOOL_NAME = 'slow_echo_mcp_e2e-memory'; const CHECK_BACKGROUND_TASK_TOOL_NAME = 'check_background_task'; +const APPROVAL_TOOL_NAME = 'approval_probe_mcp_e2e-memory'; +const APPROVAL_TOOL_CALL_PREFIX = 'call_e2e_approval_'; const BACKGROUND_DISPATCH_TOOL_CALL_ID = 'call_e2e_background_dispatch'; const BACKGROUND_COLLECT_TOOL_CALL_ID = 'call_e2e_background_collect'; const MODEL_SPEC_ACCESSIBLE_SKILL = 'e2e-model-spec-allowed'; @@ -434,12 +442,42 @@ function replyResponses(text) { * streaming pattern) so token-usage SSE events flow end to end in mock runs. */ class UsageEmittingFakeChatModel extends FakeChatModel { - constructor({ resolveOnStream, sleep, ...options }) { + constructor({ resolveInvocation, resolveOnStream, sleep, ...options }) { super({ ...options, sleep }); + this.resolveInvocation = resolveInvocation; this.resolveOnStream = resolveOnStream; this.streamSleep = sleep ?? CHUNK_DELAY_MS; } + async *streamScriptedResponseChunks({ response, toolCalls, runManager }) { + if (this.emitCustomEvent) { + await runManager?.handleCustomEvent('some_test_event', { + someval: true, + }); + } + + const chunks = response ? response.split(/(?<=\s+)|(?=\s+)/) : []; + for await (const chunk of chunks) { + await new Promise((resolve) => setTimeout(resolve, this.streamSleep)); + const responseChunk = this._createResponseChunk(chunk); + yield responseChunk; + void runManager?.handleLLMNewToken(chunk); + } + + if (toolCalls?.length) { + await new Promise((resolve) => setTimeout(resolve, this.streamSleep)); + const toolCallChunks = toolCalls.map((toolCall, index) => ({ + name: toolCall.name, + args: JSON.stringify(toolCall.args), + id: toolCall.id, + index, + type: 'tool_call_chunk', + })); + yield this._createResponseChunk('', toolCallChunks); + void runManager?.handleLLMNewToken(''); + } + } + async *streamDynamicResponseChunks({ responses, options, runManager }) { if (this.emitCustomEvent) { await runManager?.handleCustomEvent('some_test_event', { @@ -464,14 +502,26 @@ class UsageEmittingFakeChatModel extends FakeChatModel { async *_streamResponseChunks(messages, options, runManager) { let outputChars = 0; - const dynamicResponse = await this.resolveOnStream?.(messages, options, runManager); - const chunkStream = dynamicResponse - ? this.streamDynamicResponseChunks({ - responses: dynamicResponse.responses, - options, - runManager, - }) - : super._streamResponseChunks(messages, options, runManager); + const scriptedResponse = await this.resolveInvocation?.(messages, options, runManager); + const dynamicResponse = scriptedResponse + ? null + : await this.resolveOnStream?.(messages, options, runManager); + let chunkStream; + if (scriptedResponse) { + chunkStream = this.streamScriptedResponseChunks({ + response: scriptedResponse.response ?? '', + toolCalls: scriptedResponse.toolCalls, + runManager, + }); + } else if (dynamicResponse) { + chunkStream = this.streamDynamicResponseChunks({ + responses: dynamicResponse.responses, + options, + runManager, + }); + } else { + chunkStream = super._streamResponseChunks(messages, options, runManager); + } for await (const chunk of chunkStream) { outputChars += typeof chunk.text === 'string' ? chunk.text.length : 0; @@ -493,13 +543,22 @@ class UsageEmittingFakeChatModel extends FakeChatModel { } } -function overrideModel({ graph, responses, sleep, toolCalls, thrownError, resolveOnStream }) { +function overrideModel({ + graph, + responses, + sleep, + toolCalls, + thrownError, + resolveInvocation, + resolveOnStream, +}) { if (!thrownError) { graph.overrideModel = new UsageEmittingFakeChatModel({ responses, sleep: sleep ?? CHUNK_DELAY_MS, emitCustomEvent: true, toolCalls, + resolveInvocation, resolveOnStream, }); return; @@ -832,6 +891,92 @@ function findLastToolMessageText(messages, requiredToken) { return ''; } +function approvalToolResponses(label, toolNames, review) { + if (!toolNames.has(APPROVAL_TOOL_NAME)) { + return { + responses: [`E2E approval unavailable: ${APPROVAL_TOOL_NAME} was not advertised.`], + }; + } + return { + responses: ['', ''], + toolCalls: [ + { + id: `${APPROVAL_TOOL_CALL_PREFIX}${label}`, + name: APPROVAL_TOOL_NAME, + args: { + value: `original-${label}`, + ...(review ? { review } : {}), + }, + type: 'tool_call', + }, + ], + }; +} + +function batchApprovalToolResponses(label, toolNames) { + if (!toolNames.has(APPROVAL_TOOL_NAME)) { + return { + responses: [`E2E approval unavailable: ${APPROVAL_TOOL_NAME} was not advertised.`], + }; + } + return { + responses: ['', ''], + toolCalls: [ + { + id: `${APPROVAL_TOOL_CALL_PREFIX}${label}_first`, + name: APPROVAL_TOOL_NAME, + args: { value: `first-${label}` }, + type: 'tool_call', + }, + { + id: `${APPROVAL_TOOL_CALL_PREFIX}${label}_second`, + name: APPROVAL_TOOL_NAME, + args: { value: `second-${label}` }, + type: 'tool_call', + }, + ], + }; +} + +/** + * Resume rebuilds the fake model without the original prompt in `context.messages`. + * Detect the checkpoint-restored approval tool messages on every model instance + * so the continuation can report the real approve/reject/edit/respond outcome. + */ +function approvalOutcomeResponses(messages) { + let latestHumanIndex = -1; + for (let index = 0; index < (messages ?? []).length; index++) { + const type = messageType(messages[index]); + if (type === 'human' || type === 'user') { + latestHumanIndex = index; + } + } + + const outcomeMessages = (messages ?? []) + .slice(latestHumanIndex + 1) + .filter( + (message) => + messageType(message) === 'tool' && + typeof message?.tool_call_id === 'string' && + message.tool_call_id.startsWith(APPROVAL_TOOL_CALL_PREFIX), + ); + + const isBatch = outcomeMessages.some( + (message) => + message.tool_call_id.endsWith('_first') || message.tool_call_id.endsWith('_second'), + ); + if (isBatch && outcomeMessages.length < 2) { + return null; + } + + const outcomes = outcomeMessages.map((message) => getContentText(message.content)); + + if (outcomes.length === 0) { + return null; + } + return { responses: [`E2E approval outcomes: ${outcomes.join(' | ')}`] }; +} + /** * Turn 1 of the background e2e: emit the MCP tool call with the injected * `run_in_background: true` arg, then (second model invocation, after the @@ -924,7 +1069,363 @@ function backgroundCollectResponses(messages, toolNames) { }; } +function parseHandoffScript(text) { + const encodedScript = getMarkerValue(text, HANDOFF_MARKER); + if (!encodedScript) { + return null; + } + + let value; + try { + value = JSON.parse(Buffer.from(encodedScript, 'base64url').toString('utf8')); + } catch (error) { + return { + error: `could not decode marker (${error instanceof Error ? error.message : 'unknown error'})`, + }; + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { error: 'script must be an object' }; + } + if (typeof value.label !== 'string' || value.label.trim() === '') { + return { error: 'script.label must be a non-empty string' }; + } + if (!Array.isArray(value.routes) || value.routes.length === 0) { + return { error: 'script.routes must be a non-empty array' }; + } + + const routes = []; + for (const [index, route] of value.routes.entries()) { + if (!route || typeof route !== 'object' || Array.isArray(route)) { + return { error: `script.routes[${index}] must be an object` }; + } + if (typeof route.from !== 'string' || route.from === '') { + return { error: `script.routes[${index}].from must be a non-empty string` }; + } + if (typeof route.to !== 'string' || route.to === '') { + return { error: `script.routes[${index}].to must be a non-empty string` }; + } + if (route.args != null && (typeof route.args !== 'object' || Array.isArray(route.args))) { + return { error: `script.routes[${index}].args must be an object` }; + } + if (route.description != null && typeof route.description !== 'string') { + return { error: `script.routes[${index}].description must be a string` }; + } + if (route.prompt != null && typeof route.prompt !== 'string') { + return { error: `script.routes[${index}].prompt must be a string` }; + } + if (route.promptKey != null && typeof route.promptKey !== 'string') { + return { error: `script.routes[${index}].promptKey must be a string` }; + } + if (route.receipt != null && typeof route.receipt !== 'string') { + return { error: `script.routes[${index}].receipt must be a string` }; + } + if (route.targetInstructions != null && typeof route.targetInstructions !== 'string') { + return { error: `script.routes[${index}].targetInstructions must be a string` }; + } + if ( + route.targetTools != null && + (!Array.isArray(route.targetTools) || + route.targetTools.some((toolName) => typeof toolName !== 'string' || toolName === '')) + ) { + return { + error: `script.routes[${index}].targetTools must be an array of non-empty strings`, + }; + } + + const args = route.args ?? {}; + let inferredReceipt = null; + if (typeof args.instructions === 'string') { + inferredReceipt = args.instructions; + } else if (typeof args.context === 'string') { + inferredReceipt = args.context; + } + routes.push({ + from: route.from, + to: route.to, + description: route.description, + prompt: route.prompt, + promptKey: route.promptKey, + args, + receipt: route.receipt ?? inferredReceipt, + targetInstructions: route.targetInstructions, + targetTools: route.targetTools ?? [], + }); + } + + return { + script: { + label: value.label.trim(), + routes, + }, + }; +} + +function getGraphTools(agentContext) { + const result = new Map(); + const tools = + typeof agentContext?.getToolsForBinding === 'function' + ? agentContext.getToolsForBinding() + : agentContext?.graphTools; + for (const tool of tools ?? []) { + if (typeof tool?.name === 'string') { + result.set(tool.name, tool); + } + } + return result; +} + +function validateHandoffTool(route, tool, toolName) { + const failures = []; + const expectedDescription = route.description ?? `Transfer control to agent '${route.to}'`; + if (tool.description !== expectedDescription) { + failures.push( + `${toolName} description mismatch (expected "${expectedDescription}", received "${tool.description ?? ''}")`, + ); + } + + const schema = tool.schema; + const properties = + schema && + typeof schema === 'object' && + !Array.isArray(schema) && + schema.properties && + typeof schema.properties === 'object' && + !Array.isArray(schema.properties) + ? schema.properties + : null; + if (!properties) { + failures.push(`${toolName} did not expose an object properties schema`); + return failures; + } + + const propertyNames = Object.keys(properties); + if (route.prompt == null) { + if (propertyNames.length > 0) { + failures.push( + `${toolName} unexpectedly advertised input properties: ${propertyNames.join(', ')}`, + ); + } + return failures; + } + + const expectedPromptKey = route.promptKey ?? 'instructions'; + const promptProperty = properties[expectedPromptKey]; + if (!promptProperty || typeof promptProperty !== 'object' || Array.isArray(promptProperty)) { + failures.push(`${toolName} did not advertise the "${expectedPromptKey}" input property`); + return failures; + } + if (propertyNames.length !== 1) { + failures.push( + `${toolName} advertised unexpected input properties: ${propertyNames.join(', ')}`, + ); + } + if (promptProperty.type !== 'string') { + failures.push(`${toolName}.${expectedPromptKey} was not a string input`); + } + if (promptProperty.description !== route.prompt) { + failures.push( + `${toolName}.${expectedPromptKey} description mismatch (expected "${route.prompt}", received "${promptProperty.description ?? ''}")`, + ); + } + if (Array.isArray(schema.required) && schema.required.length > 0) { + failures.push(`${toolName} unexpectedly required optional handoff input`); + } + return failures; +} + +function validateHandoffScript(graph, script) { + const failures = []; + for (const route of script.routes) { + const agentContext = graph.agentContexts?.get(route.from); + if (!agentContext) { + failures.push(`source agent ${route.from} was not loaded`); + continue; + } + const toolName = `${HANDOFF_TOOL_PREFIX}${route.to}`; + const tool = getGraphTools(agentContext).get(toolName); + if (!tool) { + failures.push(`${toolName} was not advertised by source agent ${route.from}`); + continue; + } + failures.push(...validateHandoffTool(route, tool, toolName)); + } + return failures; +} + +function getAgentIdFromInvocationOptions(options, runManager) { + const metadataCandidates = [ + options?.metadata, + options?.configurable, + runManager?.metadata, + runManager?.inheritableMetadata, + ]; + for (const metadata of metadataCandidates) { + const node = metadata?.langgraph_node; + if (typeof node === 'string' && node.startsWith('agent=')) { + return node.slice('agent='.length); + } + } + return null; +} + +async function validateHandoffReception(graph, script, route, messages) { + const sourceContext = graph.agentContexts?.get(route.from); + const targetContext = graph.agentContexts?.get(route.to); + const sourceName = sourceContext?.name ?? route.from; + const targetName = targetContext?.name ?? route.to; + const promptMessages = targetContext?.systemRunnable + ? await targetContext.systemRunnable.invoke(messages ?? []) + : (messages ?? []); + const promptText = promptMessages + .map((message) => getContentText(message?.content)) + .filter(Boolean) + .join('\n'); + const failures = []; + + const identityPreamble = `You are "${targetName}", transferred from "${sourceName}".`; + if (!promptText.includes(identityPreamble)) { + failures.push(`missing identity preamble: ${identityPreamble}`); + } + + const siblingNames = Array.from( + new Set( + script.routes + .filter((candidate) => candidate !== route && candidate.from === route.from) + .map((candidate) => graph.agentContexts?.get(candidate.to)?.name ?? candidate.to), + ), + ); + const parallelPreamble = 'Running in parallel with:'; + if (siblingNames.length === 0 && promptText.includes(parallelPreamble)) { + failures.push('unexpected parallel sibling preamble'); + } + if ( + siblingNames.length > 0 && + !promptText.includes(`${parallelPreamble} ${siblingNames.join(', ')}.`) + ) { + failures.push(`missing parallel sibling preamble for ${siblingNames.join(', ')}`); + } + + if (route.targetInstructions && !promptText.includes(route.targetInstructions)) { + failures.push(`missing target instructions: ${route.targetInstructions}`); + } + + const sourceTools = getGraphTools(sourceContext); + const targetTools = getGraphTools(targetContext); + for (const toolName of route.targetTools) { + if (!targetTools.has(toolName)) { + failures.push(`target agent ${route.to} did not receive its configured tool ${toolName}`); + } + if (route.from !== route.to && sourceTools.has(toolName)) { + failures.push(`target-only tool ${toolName} leaked to source agent ${route.from}`); + } + } + + return failures; +} + +function buildHandoffResponses(graph, parsed) { + if (parsed.error) { + return { + responses: [`E2E handoff script invalid: ${parsed.error}`], + }; + } + + const { script } = parsed; + const failures = validateHandoffScript(graph, script); + if (failures.length > 0) { + return { + responses: [`E2E handoff unavailable: ${failures.join('; ')}`], + }; + } + + let invocationCount = 0; + return { + responses: [''], + resolveInvocation: async (messages, options, runManager) => { + const latestUserText = getLatestUserText(messages).trim(); + const agentId = getAgentIdFromInvocationOptions(options, runManager); + let incomingRoute = script.routes.find( + (route) => route.receipt != null && latestUserText === route.receipt.trim(), + ); + + if (!agentId) { + return { + response: `E2E handoff routing failed ${script.label}: missing SDK langgraph_node metadata`, + }; + } + invocationCount += 1; + + const incomingRoutes = script.routes.filter((route) => route.to === agentId); + if (!incomingRoute && incomingRoutes.length === 1) { + incomingRoute = incomingRoutes[0]; + } + if (incomingRoute?.receipt != null && latestUserText !== incomingRoute.receipt.trim()) { + return { + response: + `E2E handoff receipt failed ${script.label}: agent=${agentId}; ` + + `expected=${incomingRoute.receipt}; received=${latestUserText || '(empty)'}`, + }; + } + if (incomingRoute) { + const receptionFailures = await validateHandoffReception( + graph, + script, + incomingRoute, + messages, + ); + if (receptionFailures.length > 0) { + return { + response: + `E2E handoff reception failed ${script.label}: agent=${agentId}; ` + + receptionFailures.join('; '), + }; + } + } + + const outgoingRoutes = script.routes.filter((route) => route.from === agentId); + if (outgoingRoutes.length === 0) { + const received = + incomingRoute?.receipt == null ? '(no injected handoff content)' : latestUserText; + return { + response: `E2E handoff complete ${script.label}: agent=${agentId}; received=${received}`, + }; + } + + return { + response: `E2E handoff continuing ${script.label}: agent=${agentId}`, + toolCalls: outgoingRoutes.map((route, index) => ({ + id: `call_e2e_handoff_${invocationCount}_${index}_${route.to}`, + name: `${HANDOFF_TOOL_PREFIX}${route.to}`, + args: route.args, + type: 'tool_call', + })), + }; + }, + }; +} + function resolveResponses({ graph, messages, text, toolNames }) { + const batchApprovalLabel = getMarkerValue(text, TOOL_APPROVAL_BATCH_MARKER); + if (batchApprovalLabel) { + return batchApprovalToolResponses(batchApprovalLabel, toolNames); + } + + const restrictedApprovalLabel = getMarkerValue(text, TOOL_APPROVAL_RESTRICTED_MARKER); + if (restrictedApprovalLabel) { + return approvalToolResponses(restrictedApprovalLabel, toolNames, 'restricted'); + } + + const rewrittenApprovalLabel = getMarkerValue(text, TOOL_APPROVAL_REWRITE_MARKER); + if (rewrittenApprovalLabel) { + return approvalToolResponses(rewrittenApprovalLabel, toolNames, 'rewrite'); + } + + const approvalLabel = getMarkerValue(text, TOOL_APPROVAL_MARKER); + if (approvalLabel) { + return approvalToolResponses(approvalLabel, toolNames); + } + const reply = replyResponses(text); if (reply) { return reply; @@ -1049,11 +1550,26 @@ module.exports = function fakeModelHook(run, context) { const text = getLatestUserText(context?.messages); const toolNames = collectToolNames(context?.agents); - const { responses, sleep, toolCalls, thrownError, resolveOnStream } = resolveResponses({ + const handoffScript = parseHandoffScript(text); + const { responses, sleep, toolCalls, thrownError, resolveInvocation, resolveOnStream } = + handoffScript + ? buildHandoffResponses(graph, handoffScript) + : resolveResponses({ + graph, + messages: context?.messages, + text, + toolNames, + }); + overrideModel({ graph, - messages: context?.messages, - text, - toolNames, + responses, + sleep, + toolCalls, + thrownError, + resolveInvocation, + resolveOnStream: (streamMessages, streamOptions, runManager) => + approvalOutcomeResponses(streamMessages) ?? + resolveOnStream?.(streamMessages, streamOptions, runManager) ?? + null, }); - overrideModel({ graph, responses, sleep, toolCalls, thrownError, resolveOnStream }); }; diff --git a/e2e/setup/tool-approval-hook.js b/e2e/setup/tool-approval-hook.js new file mode 100644 index 0000000000..4eebaa751e --- /dev/null +++ b/e2e/setup/tool-approval-hook.js @@ -0,0 +1,29 @@ +/** + * Dynamic approval-policy fixture for the mock Playwright suite. + * + * The `review` argument selects behavior that cannot be expressed by the static + * ask list: a restricted decision set, or an authoritative argument rewrite. + */ +module.exports = () => () => async (input) => { + if (input.toolInput.review === 'restricted') { + return { + decision: 'ask', + reason: 'E2E approval offers approve or reject only.', + allowedDecisions: ['approve', 'reject'], + }; + } + + if (input.toolInput.review === 'rewrite') { + const originalValue = + typeof input.toolInput.value === 'string' ? input.toolInput.value : 'original-missing'; + return { + decision: 'ask', + reason: 'E2E approval reviews rewritten arguments.', + updatedInput: { + value: originalValue.replace(/^original-/, 'rewritten-'), + }, + }; + } + + return {}; +}; diff --git a/e2e/specs/mock/agent-handoffs.spec.ts b/e2e/specs/mock/agent-handoffs.spec.ts new file mode 100644 index 0000000000..efa419e18c --- /dev/null +++ b/e2e/specs/mock/agent-handoffs.spec.ts @@ -0,0 +1,993 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import type { GraphEdge } from 'librechat-data-provider'; +import type { AgentDetail } from './agents.helpers'; +import { cleanupAgent, openAgentBuilder, selectMockModel, uniqueAgentName } from './agents.helpers'; +import { + MOCK_ENDPOINTS, + fetchJson, + getAccessToken, + messagesView, + requestJson, + sendMessage, +} from './helpers'; + +const DESCRIPTION = 'Created by the mock end-to-end suite to verify agent handoffs.'; +const INSTRUCTIONS = 'Follow the deterministic handoff instructions from the mock model.'; +const HANDOFF_DESCRIPTION = 'Delegate requests that require specialist handling.'; +const HANDOFF_PROMPT = 'Pass the specialist the exact request and relevant constraints.'; +const HANDOFF_PROMPT_KEY = 'context'; +const MCP_SERVER_TOOL_ID = 'sys__server__sys_mcp_e2e-memory'; +const MCP_TOOL_ID = 'remember_fact_mcp_e2e-memory'; +const MCP_SERVER_NAME = 'e2e-memory'; + +type HandoffRoute = { + from: string; + to: string; + description?: string; + prompt?: string; + promptKey?: string; + args?: Record; + receipt?: string; + targetInstructions?: string; + targetTools?: string[]; +}; + +type MCPToolsResponse = { + servers?: Record }>; +}; + +const handoffMarker = (label: string, routes: HandoffRoute[]) => + `E2E_HANDOFF:${Buffer.from(JSON.stringify({ label, routes })).toString('base64url')}`; + +async function waitForMCPTool(page: Page, token: string): Promise { + let latestTools: MCPToolsResponse | null = null; + + for (let attempt = 0; attempt < 20; attempt++) { + latestTools = await fetchJson(page, '/api/mcp/tools', token); + const tools = latestTools.servers?.[MCP_SERVER_NAME]?.tools ?? []; + if (tools.some((tool) => tool.pluginKey === MCP_TOOL_ID)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + expect( + latestTools?.servers?.[MCP_SERVER_NAME]?.tools, + `Expected ${MCP_SERVER_NAME} to expose ${MCP_TOOL_ID}`, + ).toEqual(expect.arrayContaining([expect.objectContaining({ pluginKey: MCP_TOOL_ID })])); +} + +async function startNewAgent(page: Page): Promise { + let form = await openAgentBuilder(page); + const createNewButton = form.getByRole('button', { name: 'Create New Agent' }); + if (await createNewButton.isVisible().catch(() => false)) { + await createNewButton.click(); + form = page.getByRole('form', { name: 'Agent configuration form' }); + } + + await expect(form.getByRole('button', { name: 'Create', exact: true })).toBeVisible(); + return form; +} + +async function configureNewAgent(page: Page, name: string): Promise { + let form = await startNewAgent(page); + await form.getByLabel('Agent name').fill(name); + await form.getByLabel('Agent description').fill(DESCRIPTION); + await form.getByLabel('Instructions').fill(INSTRUCTIONS); + await selectMockModel(page, true); + form = page.getByRole('form', { name: 'Agent configuration form' }); + return form; +} + +async function createConfiguredAgent(form: Locator): Promise { + const page = form.page(); + const [response] = await Promise.all([ + page.waitForResponse( + (candidate) => + candidate.request().method() === 'POST' && + new URL(candidate.url()).pathname === '/api/agents' && + candidate.status() === 201, + { timeout: 30000 }, + ), + form.getByRole('button', { name: 'Create', exact: true }).click(), + ]); + return (await response.json()) as AgentDetail; +} + +async function createAgentViaApi( + page: Page, + token: string, + name: string, + edges?: GraphEdge[], + overrides: { instructions?: string; tools?: string[] } = {}, +): Promise { + return requestJson(page, { + path: '/api/agents', + token, + method: 'POST', + body: { + name, + description: DESCRIPTION, + instructions: INSTRUCTIONS, + provider: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + edges, + ...overrides, + }, + }); +} + +async function selectAgentForChat(page: Page, agentName: string): Promise { + const form = await openAgentBuilder(page); + await form.getByRole('combobox', { name: 'Agent', exact: true }).click(); + await page.getByRole('option', { name: agentName }).click(); + await expect(form.getByLabel('Agent name')).toHaveValue(agentName); + await form.getByRole('button', { name: 'Select Agent' }).click(); + await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible(); +} + +async function cleanupAgents( + page: Page, + token: string, + agentIds: Array, +): Promise { + for (const agentId of agentIds.reverse()) { + if (!agentId) { + continue; + } + await requestJson(page, { + path: `/api/agents/${encodeURIComponent(agentId)}`, + token, + method: 'DELETE', + }).catch(() => undefined); + } +} + +test.describe('agent handoffs', () => { + test.describe.configure({ timeout: 60_000 }); + + test('creates and runs a router with handoffs selected before the router exists', async ({ + page, + }) => { + test.setTimeout(180000); + + const specialistName = uniqueAgentName('E2E Handoff Specialist'); + const bareSpecialistName = uniqueAgentName('E2E Bare Handoff Specialist'); + const routerName = uniqueAgentName('E2E Handoff Router'); + let specialistId: string | undefined; + let bareSpecialistId: string | undefined; + let routerId: string | undefined; + + try { + const specialistForm = await configureNewAgent(page, specialistName); + const specialist = await createConfiguredAgent(specialistForm); + specialistId = specialist.id; + + const bareSpecialistForm = await configureNewAgent(page, bareSpecialistName); + const bareSpecialist = await createConfiguredAgent(bareSpecialistForm); + bareSpecialistId = bareSpecialist.id; + + const routerForm = await configureNewAgent(page, routerName); + await routerForm.getByRole('button', { name: 'Advanced' }).click(); + const handoffs = routerForm.getByRole('region', { name: 'Handoffs' }); + await expect(handoffs).toBeVisible(); + + await handoffs.getByRole('combobox', { name: 'Add agent' }).click(); + await page.getByRole('option', { name: specialistName }).click(); + await expect(handoffs.getByText('1 / 10', { exact: true })).toBeVisible(); + await handoffs.getByRole('button', { name: 'Expand' }).click(); + await handoffs.getByLabel('Handoff description').fill(HANDOFF_DESCRIPTION); + await handoffs.getByLabel('Passthrough content').fill(HANDOFF_PROMPT); + await handoffs + .getByLabel("Content parameter name (default: 'instructions')") + .fill(HANDOFF_PROMPT_KEY); + await handoffs.getByRole('combobox', { name: 'Add agent' }).click(); + await page.getByRole('option', { name: bareSpecialistName }).click(); + await expect(handoffs.getByText('2 / 10', { exact: true })).toBeVisible(); + + const router = await createConfiguredAgent(routerForm); + routerId = router.id; + + const token = await getAccessToken(page); + const persisted = await fetchJson( + page, + `/api/agents/${encodeURIComponent(router.id)}/expanded`, + token, + ); + + expect(persisted.edges).toEqual([ + { + from: router.id, + to: specialist.id, + edgeType: 'handoff', + description: HANDOFF_DESCRIPTION, + prompt: HANDOFF_PROMPT, + promptKey: HANDOFF_PROMPT_KEY, + }, + { + from: router.id, + to: bareSpecialist.id, + edgeType: 'handoff', + }, + ]); + + const reopenedForm = await openAgentBuilder(page); + await reopenedForm.getByRole('combobox', { name: 'Agent', exact: true }).click(); + await page.getByRole('option', { name: routerName }).click(); + await reopenedForm.getByRole('button', { name: 'Advanced' }).click(); + const reopenedHandoffs = reopenedForm.getByRole('region', { name: 'Handoffs' }); + await expect(reopenedHandoffs.getByText('2 / 10', { exact: true })).toBeVisible(); + const reopenedDestinations = reopenedHandoffs.getByRole('combobox', { + name: 'Select agent', + }); + await expect(reopenedDestinations).toHaveCount(2); + await expect(reopenedDestinations.first()).toContainText(specialistName); + await expect(reopenedDestinations.last()).toContainText(bareSpecialistName); + await reopenedHandoffs.getByRole('button', { name: 'Expand' }).first().click(); + await expect(reopenedHandoffs.getByLabel('Handoff description')).toHaveValue( + HANDOFF_DESCRIPTION, + ); + await expect(reopenedHandoffs.getByLabel('Passthrough content')).toHaveValue(HANDOFF_PROMPT); + await expect( + reopenedHandoffs.getByLabel("Content parameter name (default: 'instructions')"), + ).toHaveValue(HANDOFF_PROMPT_KEY); + + await reopenedForm.getByRole('button', { name: 'Select Agent' }).click(); + await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible(); + + const label = `scratch-bare-${Date.now()}`; + const response = await sendMessage( + page, + handoffMarker(label, [ + { + from: router.id, + to: bareSpecialist.id, + args: {}, + }, + ]), + ); + expect(response.ok()).toBeTruthy(); + await expect( + messagesView(page).getByText( + `E2E handoff complete ${label}: agent=${bareSpecialist.id}; received=(no injected handoff content)`, + { exact: true }, + ), + ).toBeVisible({ timeout: 30000 }); + await expect( + messagesView(page).getByRole('button', { + name: `Transferred to ${bareSpecialistName}`, + }), + ).toBeDisabled(); + } finally { + await cleanupAgent(page, routerId); + await cleanupAgent(page, bareSpecialistId); + await cleanupAgent(page, specialistId); + } + }); + + test('moves copied handoffs from the original router to its duplicate', async ({ page }) => { + test.setTimeout(120000); + + await page.goto('/c/new', { timeout: 10000 }); + const token = await getAccessToken(page); + const targetName = uniqueAgentName('E2E Handoff Clone Target'); + const routerName = uniqueAgentName('E2E Handoff Clone Router'); + let targetId: string | undefined; + let routerId: string | undefined; + let cloneId: string | undefined; + + try { + const target = await createAgentViaApi(page, token, targetName); + targetId = target.id; + const router = await createAgentViaApi(page, token, routerName, [ + { + from: '', + to: target.id, + edgeType: 'handoff', + description: 'Delegate clone work', + prompt: 'Preserve this payload', + promptKey: 'instructions', + }, + ]); + routerId = router.id; + + const duplicate = await requestJson<{ agent: AgentDetail }>(page, { + path: `/api/agents/${encodeURIComponent(router.id)}/duplicate`, + token, + method: 'POST', + }); + cloneId = duplicate.agent.id; + + expect(duplicate.agent.edges).toEqual([ + { + from: duplicate.agent.id, + to: target.id, + edgeType: 'handoff', + description: 'Delegate clone work', + prompt: 'Preserve this payload', + promptKey: 'instructions', + }, + ]); + } finally { + await cleanupAgent(page, cloneId); + await cleanupAgent(page, routerId); + await cleanupAgent(page, targetId); + } + }); + + test('edits, saves, reopens, and restores handoff versions without duplicate destinations', async ({ + page, + }) => { + test.setTimeout(240000); + + await page.goto('/c/new', { timeout: 10000 }); + const token = await getAccessToken(page); + const firstName = uniqueAgentName('E2E Editable Handoff First'); + const secondName = uniqueAgentName('E2E Editable Handoff Second'); + const thirdName = uniqueAgentName('E2E Editable Handoff Third'); + const routerName = uniqueAgentName('E2E Editable Handoff Router'); + const createdIds: string[] = []; + let routerId: string | undefined; + + try { + const first = await createAgentViaApi(page, token, firstName); + const second = await createAgentViaApi(page, token, secondName); + const third = await createAgentViaApi(page, token, thirdName); + createdIds.push(first.id, second.id, third.id); + + const routerForm = await configureNewAgent(page, routerName); + await routerForm.getByRole('button', { name: 'Advanced' }).click(); + const handoffs = routerForm.getByRole('region', { name: 'Handoffs' }); + const addAgent = handoffs.getByRole('combobox', { name: 'Add agent' }); + + await addAgent.click(); + await page.getByRole('option', { name: firstName }).click(); + await addAgent.click(); + await expect(page.getByRole('option', { name: firstName })).toHaveCount(0); + await page.getByRole('option', { name: secondName }).click(); + await expect(handoffs.getByText('2 / 10', { exact: true })).toBeVisible(); + + const expandButtons = handoffs.getByRole('button', { name: 'Expand' }); + await expandButtons.first().click(); + await expandButtons.first().click(); + await handoffs + .getByLabel('Handoff description') + .nth(1) + .fill('The surviving expanded handoff'); + + await handoffs.getByRole('button', { name: `Remove handoff to ${firstName}` }).click(); + await expect(handoffs.getByText('1 / 10', { exact: true })).toBeVisible(); + await expect(handoffs.getByText(secondName, { exact: true })).toBeVisible(); + await expect(handoffs.getByLabel('Handoff description')).toHaveValue( + 'The surviving expanded handoff', + ); + + const destination = handoffs.getByRole('combobox', { name: 'Select agent' }); + await destination.click(); + const destinationDialog = page.getByRole('dialog', { name: 'Select agent' }).last(); + await expect(destinationDialog.getByRole('option', { name: firstName })).toBeVisible(); + await expect(destinationDialog.getByRole('option', { name: thirdName })).toBeVisible(); + await destinationDialog.getByRole('option', { name: firstName }).click(); + + await addAgent.click(); + const addDialog = page.getByRole('dialog', { name: 'Add agent' }); + await expect(addDialog.getByRole('option', { name: firstName })).toHaveCount(0); + await expect(addDialog.getByRole('option', { name: secondName })).toBeVisible(); + await addDialog.getByRole('option', { name: thirdName }).click(); + + const router = await createConfiguredAgent(routerForm); + routerId = router.id; + const persisted = await fetchJson( + page, + `/api/agents/${encodeURIComponent(router.id)}/expanded`, + token, + ); + expect(persisted.edges).toEqual([ + { + from: router.id, + to: first.id, + edgeType: 'handoff', + description: 'The surviving expanded handoff', + }, + { + from: router.id, + to: third.id, + edgeType: 'handoff', + }, + ]); + + let editForm = await openAgentBuilder(page); + await editForm.getByRole('combobox', { name: 'Agent', exact: true }).click(); + await page.getByRole('option', { name: routerName }).click(); + await expect(editForm.getByLabel('Agent name')).toHaveValue(routerName); + await editForm.getByRole('button', { name: 'Advanced' }).click(); + + let editableHandoffs = editForm.getByRole('region', { name: 'Handoffs' }); + await expect(editableHandoffs.getByText('2 / 10', { exact: true })).toBeVisible(); + await editableHandoffs.getByRole('button', { name: 'Expand' }).first().click(); + await editableHandoffs + .getByLabel('Handoff description') + .fill('The updated persisted handoff'); + const secondDestination = editableHandoffs + .getByRole('combobox', { name: 'Select agent' }) + .nth(1); + await secondDestination.click(); + await page + .getByRole('dialog', { name: 'Select agent' }) + .last() + .getByRole('option', { name: secondName }) + .click(); + + await editForm.getByRole('button', { name: 'Back to builder' }).click(); + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'PATCH' && + new URL(response.url()).pathname === `/api/agents/${router.id}` && + response.ok(), + { timeout: 30000 }, + ), + editForm.getByRole('button', { name: 'Save', exact: true }).click(), + ]); + expect(updateResponse.ok()).toBeTruthy(); + + const updated = await fetchJson( + page, + `/api/agents/${encodeURIComponent(router.id)}/expanded`, + token, + ); + expect(updated.edges).toEqual([ + { + from: router.id, + to: first.id, + edgeType: 'handoff', + description: 'The updated persisted handoff', + }, + { + from: router.id, + to: second.id, + edgeType: 'handoff', + }, + ]); + + editForm = await openAgentBuilder(page); + await editForm.getByRole('combobox', { name: 'Agent', exact: true }).click(); + await page.getByRole('option', { name: routerName }).click(); + await editForm.getByRole('button', { name: 'Advanced' }).click(); + editableHandoffs = editForm.getByRole('region', { name: 'Handoffs' }); + await expect(editableHandoffs.getByText('2 / 10', { exact: true })).toBeVisible(); + await expect( + editableHandoffs.getByRole('combobox', { name: 'Select agent' }).first(), + ).toContainText(firstName); + await expect( + editableHandoffs.getByRole('combobox', { name: 'Select agent' }).nth(1), + ).toContainText(secondName); + await editableHandoffs.getByRole('button', { name: 'Expand' }).first().click(); + await expect(editableHandoffs.getByLabel('Handoff description')).toHaveValue( + 'The updated persisted handoff', + ); + + await editForm.getByRole('button', { name: 'Back to builder' }).click(); + await editForm.getByRole('button', { name: 'Version', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Version History' })).toBeVisible(); + const history = page.getByRole('list', { name: 'Version History' }); + const versionItems = history.getByRole('listitem'); + await expect(versionItems).toHaveCount(2); + await expect(versionItems.first()).toHaveAttribute('aria-current', 'true'); + await expect(versionItems.last()).not.toHaveAttribute('aria-current'); + + await versionItems.last().getByRole('button', { name: 'Restore' }).click(); + const restoreDialog = page.getByRole('dialog', { + name: 'Are you sure you want to restore this version?', + }); + const [restoreResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'POST' && + new URL(response.url()).pathname === `/api/agents/${router.id}/revert` && + response.ok(), + { timeout: 30000 }, + ), + restoreDialog.getByRole('button', { name: 'Restore', exact: true }).click(), + ]); + expect(restoreResponse.ok()).toBeTruthy(); + await expect(page.getByText('Version restored successfully', { exact: true })).toBeVisible(); + await expect(versionItems.last()).toHaveAttribute('aria-current', 'true'); + + await page.getByRole('button', { name: 'Back to builder' }).click(); + editForm = page.getByRole('form', { name: 'Agent configuration form' }); + await editForm.getByRole('button', { name: 'Advanced' }).click(); + editableHandoffs = editForm.getByRole('region', { name: 'Handoffs' }); + await expect( + editableHandoffs.getByRole('combobox', { name: 'Select agent' }).first(), + ).toContainText(firstName); + await expect( + editableHandoffs.getByRole('combobox', { name: 'Select agent' }).nth(1), + ).toContainText(thirdName); + + const restored = await fetchJson( + page, + `/api/agents/${encodeURIComponent(router.id)}/expanded`, + token, + ); + expect(restored.edges).toEqual(persisted.edges); + } finally { + await cleanupAgents(page, token, [routerId, ...createdIds]); + } + }); + + test('enforces the ten-destination handoff limit in the builder', async ({ page }) => { + test.setTimeout(240000); + + await page.goto('/c/new', { timeout: 10000 }); + const token = await getAccessToken(page); + const targetNames = Array.from({ length: 10 }, (_, index) => + uniqueAgentName(`E2E Handoff Limit ${index + 1}`), + ); + const targetIds: string[] = []; + + try { + for (const targetName of targetNames) { + const target = await createAgentViaApi(page, token, targetName); + targetIds.push(target.id); + } + + const routerForm = await configureNewAgent(page, uniqueAgentName('E2E Handoff Limit Router')); + await routerForm.getByRole('button', { name: 'Advanced' }).click(); + const handoffs = routerForm.getByRole('region', { name: 'Handoffs' }); + + for (const targetName of targetNames) { + await handoffs.getByRole('combobox', { name: 'Add agent' }).click(); + await page.getByRole('option', { name: targetName }).click(); + } + + await expect(handoffs.getByText('10 / 10', { exact: true })).toBeVisible(); + await expect( + handoffs.getByText('Maximum 10 handoff agents reached.', { exact: true }), + ).toBeVisible(); + await expect(handoffs.getByRole('combobox', { name: 'Add agent' })).toHaveCount(0); + await expect(handoffs.getByRole('combobox', { name: 'Select agent' })).toHaveCount(10); + } finally { + await cleanupAgents(page, token, targetIds); + } + }); + + test('refreshes a cached router after its handoff target is deleted', async ({ page }) => { + test.setTimeout(180000); + + await page.goto('/c/new', { timeout: 10000 }); + const token = await getAccessToken(page); + const targetName = uniqueAgentName('E2E Deleted Handoff Target'); + const routerName = uniqueAgentName('E2E Cached Handoff Router'); + let routerId: string | undefined; + + try { + const target = await createAgentViaApi(page, token, targetName); + const router = await createAgentViaApi(page, token, routerName, [ + { + from: '', + to: target.id, + edgeType: 'handoff', + description: 'This edge should disappear with its target.', + }, + ]); + routerId = router.id; + + let form = await openAgentBuilder(page); + await form.getByRole('combobox', { name: 'Agent', exact: true }).click(); + await page.getByRole('option', { name: routerName }).click(); + await form.getByRole('button', { name: 'Advanced' }).click(); + await expect( + form.getByRole('region', { name: 'Handoffs' }).getByText('1 / 10', { exact: true }), + ).toBeVisible(); + + await form.getByRole('button', { name: 'Back to builder' }).click(); + await form.getByRole('combobox', { name: 'Agent', exact: true }).click(); + await page.getByRole('option', { name: targetName }).click(); + await expect(form.getByLabel('Agent name')).toHaveValue(targetName); + await form.getByRole('button', { name: 'Delete Agent' }).click(); + const dialog = page.getByRole('dialog', { name: 'Delete Agent' }); + await expect(dialog).toBeVisible(); + const [deleteResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'DELETE' && + new URL(response.url()).pathname === `/api/agents/${target.id}` && + response.ok(), + { timeout: 30000 }, + ), + dialog.getByRole('button', { name: 'Delete', exact: true }).click(), + ]); + expect(deleteResponse.ok()).toBeTruthy(); + + form = page.getByRole('form', { name: 'Agent configuration form' }); + await expect(form.getByLabel('Agent name')).toHaveValue(routerName, { timeout: 30000 }); + await form.getByRole('button', { name: 'Advanced' }).click(); + const handoffs = form.getByRole('region', { name: 'Handoffs' }); + await expect(handoffs.getByText('0 / 10', { exact: true })).toBeVisible({ + timeout: 30000, + }); + await expect(handoffs.getByText(targetName, { exact: true })).toHaveCount(0); + + const persisted = await fetchJson( + page, + `/api/agents/${encodeURIComponent(router.id)}/expanded`, + token, + ); + expect(persisted.edges ?? []).toEqual([]); + } finally { + await cleanupAgents(page, token, [routerId]); + } + }); + + test('rejects a stale handoff when its target no longer exists', async ({ page }) => { + test.setTimeout(120000); + + await page.goto('/c/new', { timeout: 10000 }); + const token = await getAccessToken(page); + const target = await createAgentViaApi( + page, + token, + uniqueAgentName('E2E Missing Handoff Target'), + ); + const router = await createAgentViaApi( + page, + token, + uniqueAgentName('E2E Missing Handoff Router'), + [{ from: '', to: target.id, edgeType: 'handoff' }], + ); + + try { + await requestJson(page, { + path: `/api/agents/${encodeURIComponent(target.id)}`, + token, + method: 'DELETE', + }); + + const staleSave = await page.request.patch(`/api/agents/${encodeURIComponent(router.id)}`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + edges: [{ from: router.id, to: target.id, edgeType: 'handoff' }], + }, + }); + expect(staleSave.status()).toBe(400); + await expect(staleSave.json()).resolves.toMatchObject({ + error: 'One or more agents referenced in edges do not exist', + agent_ids: [target.id], + }); + } finally { + await cleanupAgents(page, token, [router.id]); + } + }); + + test('routes to the chosen agent, renders passthrough details, and survives reloads', async ({ + page, + }) => { + test.setTimeout(180000); + + await page.goto('/c/new', { timeout: 10000 }); + const token = await getAccessToken(page); + const chosenName = uniqueAgentName('E2E Chosen Handoff'); + const unusedName = uniqueAgentName('E2E Unused Handoff'); + const routerName = uniqueAgentName('E2E Choice Router'); + const label = `choice-${Date.now()}`; + const payload = `receipt-${Date.now()}`; + const chosenInstructions = `Only the chosen specialist has this instruction marker: ${label}.`; + let chosenId: string | undefined; + let unusedId: string | undefined; + let routerId: string | undefined; + + try { + await waitForMCPTool(page, token); + const chosen = await createAgentViaApi(page, token, chosenName, undefined, { + instructions: chosenInstructions, + tools: [MCP_SERVER_TOOL_ID, MCP_TOOL_ID], + }); + chosenId = chosen.id; + const unused = await createAgentViaApi(page, token, unusedName); + unusedId = unused.id; + const router = await createAgentViaApi(page, token, routerName, [ + { + from: '', + to: chosen.id, + edgeType: 'handoff', + description: 'Use the chosen specialist for this request.', + prompt: 'Pass precise instructions to the chosen specialist.', + promptKey: 'brief', + }, + { + from: '', + to: unused.id, + edgeType: 'handoff', + description: 'A valid alternative that should not be selected.', + }, + ]); + routerId = router.id; + + await selectAgentForChat(page, routerName); + const noTransferLabel = `no-transfer-${Date.now()}`; + const noTransferResponse = await sendMessage(page, `E2E_REPLY:${noTransferLabel}`); + expect(noTransferResponse.ok()).toBeTruthy(); + await expect( + messagesView(page).getByText(`E2E reply ${noTransferLabel}`, { exact: true }), + ).toBeVisible({ timeout: 30000 }); + await expect( + messagesView(page).getByRole('button', { name: /^Transferred to / }), + ).toHaveCount(0); + + const response = await sendMessage( + page, + handoffMarker(label, [ + { + from: router.id, + to: chosen.id, + description: 'Use the chosen specialist for this request.', + prompt: 'Pass precise instructions to the chosen specialist.', + promptKey: 'brief', + args: { brief: payload }, + receipt: payload, + targetInstructions: chosenInstructions, + targetTools: [MCP_TOOL_ID], + }, + ]), + ); + expect(response.ok()).toBeTruthy(); + + const finalText = `E2E handoff complete ${label}: agent=${chosen.id}; received=${payload}`; + await expect(messagesView(page).getByText(finalText, { exact: true })).toBeVisible({ + timeout: 30000, + }); + await expect( + messagesView(page).getByRole('button', { name: `Transferred to ${unusedName}` }), + ).toHaveCount(0); + + let transferCard = messagesView(page).getByRole('button', { + name: `Transferred to ${chosenName}`, + }); + await expect(transferCard).toBeEnabled(); + await transferCard.click(); + await expect( + messagesView(page).getByText('Handoff instructions:', { exact: true }), + ).toBeVisible(); + await expect( + messagesView(page).getByText(JSON.stringify({ brief: payload }), { exact: true }), + ).toBeVisible(); + + await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 }); + const conversationUrl = page.url(); + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page).toHaveURL(conversationUrl); + await expect(messagesView(page).getByText(finalText, { exact: true })).toBeVisible({ + timeout: 30000, + }); + transferCard = messagesView(page).getByRole('button', { + name: `Transferred to ${chosenName}`, + }); + await expect(transferCard).toBeVisible(); + + const emptyLabel = `${label}-empty`; + const emptyResponse = await sendMessage( + page, + handoffMarker(emptyLabel, [ + { + from: router.id, + to: chosen.id, + description: 'Use the chosen specialist for this request.', + prompt: 'Pass precise instructions to the chosen specialist.', + promptKey: 'brief', + args: {}, + }, + ]), + ); + expect(emptyResponse.ok()).toBeTruthy(); + await expect( + messagesView(page).getByText( + `E2E handoff complete ${emptyLabel}: agent=${chosen.id}; received=(no injected handoff content)`, + { exact: true }, + ), + ).toBeVisible({ timeout: 30000 }); + await expect( + messagesView(page) + .getByRole('button', { name: `Transferred to ${chosenName}` }) + .last(), + ).toBeDisabled(); + } finally { + await cleanupAgent(page, routerId); + await cleanupAgent(page, unusedId); + await cleanupAgent(page, chosenId); + } + }); + + test('executes a transitive router-to-specialist-to-reviewer handoff', async ({ page }) => { + test.setTimeout(180000); + + await page.goto('/c/new', { timeout: 10000 }); + const token = await getAccessToken(page); + const reviewerName = uniqueAgentName('E2E Handoff Reviewer'); + const specialistName = uniqueAgentName('E2E Handoff Middle'); + const routerName = uniqueAgentName('E2E Handoff Chain Router'); + const label = `chain-${Date.now()}`; + const specialistReceipt = `specialist-context-${Date.now()}`; + const reviewerReceipt = `reviewer-context-${Date.now()}`; + let reviewerId: string | undefined; + let specialistId: string | undefined; + let routerId: string | undefined; + + try { + const reviewer = await createAgentViaApi(page, token, reviewerName); + reviewerId = reviewer.id; + const specialist = await createAgentViaApi(page, token, specialistName, [ + { + from: '', + to: reviewer.id, + edgeType: 'handoff', + description: 'Send completed specialist work to review.', + prompt: 'Pass review context.', + promptKey: 'context', + }, + ]); + specialistId = specialist.id; + const router = await createAgentViaApi(page, token, routerName, [ + { + from: '', + to: specialist.id, + edgeType: 'handoff', + description: 'Start with the specialist.', + prompt: 'Pass specialist instructions.', + }, + ]); + routerId = router.id; + + await selectAgentForChat(page, routerName); + const response = await sendMessage( + page, + handoffMarker(label, [ + { + from: router.id, + to: specialist.id, + description: 'Start with the specialist.', + prompt: 'Pass specialist instructions.', + args: { instructions: specialistReceipt }, + }, + { + from: specialist.id, + to: reviewer.id, + description: 'Send completed specialist work to review.', + prompt: 'Pass review context.', + promptKey: 'context', + args: { context: reviewerReceipt }, + }, + ]), + ); + expect(response.ok()).toBeTruthy(); + + await expect( + messagesView(page).getByText( + `E2E handoff complete ${label}: agent=${reviewer.id}; received=${reviewerReceipt}`, + { exact: true }, + ), + ).toBeVisible({ timeout: 30000 }); + await expect( + messagesView(page).getByRole('button', { name: `Transferred to ${specialistName}` }), + ).toBeVisible(); + await expect( + messagesView(page).getByRole('button', { name: `Transferred to ${reviewerName}` }), + ).toBeVisible(); + } finally { + await cleanupAgent(page, routerId); + await cleanupAgent(page, specialistId); + await cleanupAgent(page, reviewerId); + } + }); + + test('executes simultaneous handoffs and renders both transfer branches', async ({ page }) => { + test.setTimeout(180000); + + await page.goto('/c/new', { timeout: 10000 }); + const token = await getAccessToken(page); + const leftName = uniqueAgentName('E2E Parallel Left'); + const rightName = uniqueAgentName('E2E Parallel Right'); + const routerName = uniqueAgentName('E2E Parallel Router'); + const label = `parallel-${Date.now()}`; + const leftReceipt = `left-context-${Date.now()}`; + const rightReceipt = `right-context-${Date.now()}`; + let leftId: string | undefined; + let rightId: string | undefined; + let routerId: string | undefined; + + try { + const left = await createAgentViaApi(page, token, leftName); + leftId = left.id; + const right = await createAgentViaApi(page, token, rightName); + rightId = right.id; + const router = await createAgentViaApi(page, token, routerName, [ + { + from: '', + to: left.id, + edgeType: 'handoff', + description: 'Run the left branch.', + prompt: 'Pass left-branch instructions.', + }, + { + from: '', + to: right.id, + edgeType: 'handoff', + description: 'Run the right branch.', + prompt: 'Pass right-branch context.', + promptKey: 'context', + }, + ]); + routerId = router.id; + + await selectAgentForChat(page, routerName); + const response = await sendMessage( + page, + handoffMarker(label, [ + { + from: router.id, + to: left.id, + description: 'Run the left branch.', + prompt: 'Pass left-branch instructions.', + args: { instructions: leftReceipt }, + }, + { + from: router.id, + to: right.id, + description: 'Run the right branch.', + prompt: 'Pass right-branch context.', + promptKey: 'context', + args: { context: rightReceipt }, + }, + ]), + ); + expect(response.ok()).toBeTruthy(); + + await expect( + messagesView(page).getByText( + `E2E handoff complete ${label}: agent=${left.id}; received=${leftReceipt}`, + { exact: true }, + ), + ).toBeVisible({ timeout: 30000 }); + await expect( + messagesView(page).getByText( + `E2E handoff complete ${label}: agent=${right.id}; received=${rightReceipt}`, + { exact: true }, + ), + ).toBeVisible({ timeout: 30000 }); + await expect( + messagesView(page).getByRole('button', { name: `Transferred to ${leftName}` }), + ).toBeVisible(); + await expect( + messagesView(page).getByRole('button', { name: `Transferred to ${rightName}` }), + ).toBeVisible(); + + await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 }); + const conversationUrl = page.url(); + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page).toHaveURL(conversationUrl); + await expect( + messagesView(page).getByText( + `E2E handoff complete ${label}: agent=${left.id}; received=${leftReceipt}`, + { exact: true }, + ), + ).toBeVisible({ timeout: 30000 }); + await expect( + messagesView(page).getByText( + `E2E handoff complete ${label}: agent=${right.id}; received=${rightReceipt}`, + { exact: true }, + ), + ).toBeVisible({ timeout: 30000 }); + await expect( + messagesView(page).getByRole('button', { name: `Transferred to ${leftName}` }), + ).toBeVisible(); + await expect( + messagesView(page).getByRole('button', { name: `Transferred to ${rightName}` }), + ).toBeVisible(); + } finally { + await cleanupAgent(page, routerId); + await cleanupAgent(page, rightId); + await cleanupAgent(page, leftId); + } + }); +}); diff --git a/e2e/specs/mock/agents.helpers.ts b/e2e/specs/mock/agents.helpers.ts index bf5566f6af..cdfded2f2e 100644 --- a/e2e/specs/mock/agents.helpers.ts +++ b/e2e/specs/mock/agents.helpers.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import type { GraphEdge } from 'librechat-data-provider'; import type { Page } from '@playwright/test'; import { MOCK_ENDPOINTS, NEW_CHAT_PATH, fetchJson, getAccessToken, requestJson } from './helpers'; @@ -33,6 +34,7 @@ export type AgentDetail = AgentSummary & { model_parameters?: ModelParameters; tools?: string[]; mcpServerNames?: string[]; + edges?: GraphEdge[]; }; export const uniqueAgentName = (prefix: string) => diff --git a/e2e/specs/mock/tool-approvals.spec.ts b/e2e/specs/mock/tool-approvals.spec.ts new file mode 100644 index 0000000000..7e38a4059b --- /dev/null +++ b/e2e/specs/mock/tool-approvals.spec.ts @@ -0,0 +1,835 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { expect, test } from '@playwright/test'; +import type { Locator, Page, Request, Route } from '@playwright/test'; +import type { AgentDetail } from './agents.helpers'; +import { cleanupAgent, openAgentBuilder, uniqueAgentName } from './agents.helpers'; +import { + MOCK_ENDPOINTS, + NEW_CHAT_PATH, + fetchJson, + getAccessToken, + messagesView, + requestJson, + sendMessage, +} from './helpers'; + +const MCP_SERVER_NAME = 'e2e-memory'; +const MCP_SERVER_TOOL_ID = `sys__server__sys_mcp_${MCP_SERVER_NAME}`; +const APPROVAL_TOOL_NAME = 'approval_probe'; +const APPROVAL_TOOL_ID = `${APPROVAL_TOOL_NAME}_mcp_${MCP_SERVER_NAME}`; +const APPROVAL_PROMPT_MARKER = 'E2E_TOOL_APPROVAL:'; +const BATCH_APPROVAL_PROMPT_MARKER = 'E2E_TOOL_APPROVAL_BATCH:'; +const RESTRICTED_APPROVAL_PROMPT_MARKER = 'E2E_TOOL_APPROVAL_RESTRICTED:'; +const REWRITTEN_APPROVAL_PROMPT_MARKER = 'E2E_TOOL_APPROVAL_REWRITE:'; +const APPROVAL_REASON = `E2E approval required before running ${APPROVAL_TOOL_ID}.`; +const APPROVAL_ERROR = 'Something went wrong submitting your decision. Please try again.'; +const APPROVAL_EXPIRED = 'This request expired or was already handled.'; +const DESCRIPTION = 'Verifies human approval behavior for MCP tool calls in mock E2E tests.'; +const APPROVAL_AUDIT_DIR = path.join('/tmp', 'librechat-e2e-approval-audit'); +const uniqueLabel = () => `${Date.now()}-${Math.floor(Math.random() * 1e4)}`; +const approvalInvocationPath = (value: string) => + path.join(APPROVAL_AUDIT_DIR, Buffer.from(value).toString('base64url')); + +function clearApprovalInvocations(...values: string[]) { + values.forEach((value) => fs.rmSync(approvalInvocationPath(value), { force: true })); +} + +function approvalInvocationCount(value: string) { + const filename = approvalInvocationPath(value); + if (!fs.existsSync(filename)) { + return 0; + } + return fs + .readFileSync(filename, 'utf8') + .split('\n') + .filter((line) => line.length > 0).length; +} + +async function expectApprovalInvocationCount(value: string, count: number) { + await expect.poll(() => approvalInvocationCount(value), { timeout: 30000 }).toBe(count); +} + +type MCPToolsResponse = { + servers?: Record }>; +}; + +type ApprovalResumeBody = { + actionId?: string; + agent_id?: string; + conversationId?: string; + endpoint?: string; + decisions?: Array<{ + tool_call_id?: string; + decision?: string; + reason?: string; + responseText?: string; + editedArguments?: Record; + }>; +}; + +type ApprovalResumeResponse = { + conversationId?: string; + status?: string; + streamId?: string; +}; + +const approvalCards = (page: Page) => messagesView(page).getByTestId('tool-approval'); +const approvalCard = (page: Page, toolCallId: string) => + messagesView(page).locator(`[data-testid="tool-approval"][data-tool-call-id="${toolCallId}"]`); + +function isResumeRequest(request: Request) { + return ( + request.method() === 'POST' && new URL(request.url()).pathname === '/api/agents/chat/resume' + ); +} + +async function waitForApprovalTool(page: Page) { + const token = await getAccessToken(page); + let latestTools: MCPToolsResponse | null = null; + + for (let attempt = 0; attempt < 20; attempt++) { + latestTools = await fetchJson(page, '/api/mcp/tools', token); + const tools = latestTools.servers?.[MCP_SERVER_NAME]?.tools ?? []; + if (tools.some((tool) => tool.pluginKey === APPROVAL_TOOL_ID)) { + return; + } + await page.waitForTimeout(500); + } + + expect( + latestTools?.servers?.[MCP_SERVER_NAME]?.tools, + `Expected ${MCP_SERVER_NAME} to expose ${APPROVAL_TOOL_ID}`, + ).toEqual(expect.arrayContaining([expect.objectContaining({ pluginKey: APPROVAL_TOOL_ID })])); +} + +async function createAndSelectApprovalAgent(page: Page): Promise { + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + await waitForApprovalTool(page); + + const token = await getAccessToken(page); + const agentName = uniqueAgentName('E2E Tool Approval Agent'); + const agent = await requestJson(page, { + path: '/api/agents', + token, + method: 'POST', + body: { + name: agentName, + description: DESCRIPTION, + instructions: 'Use the requested approval probe tools and report their results.', + provider: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + tools: [MCP_SERVER_TOOL_ID, APPROVAL_TOOL_ID], + }, + }); + + const form = await openAgentBuilder(page); + await form.getByRole('combobox', { name: 'Agent', exact: true }).click(); + await page.getByRole('option', { name: agentName }).click(); + await expect(form.getByLabel('Agent name')).toHaveValue(agentName); + await form.getByRole('button', { name: 'Select Agent' }).click(); + return agent.id; +} + +async function startApproval( + page: Page, + label: string, + marker = APPROVAL_PROMPT_MARKER, + expectedReason = APPROVAL_REASON, +): Promise { + const response = await sendMessage(page, `${marker}${label}`); + expect(response.ok()).toBeTruthy(); + await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 }); + const card = approvalCards(page).first(); + await expect(card).toBeVisible({ timeout: 30000 }); + await expect(card).toContainText(expectedReason); + return card; +} + +async function submitAndCapture(page: Page, submit: Locator) { + const [request, response] = await Promise.all([ + page.waitForRequest(isResumeRequest), + page.waitForResponse( + (candidate) => isResumeRequest(candidate.request()) && candidate.status() === 200, + ), + submit.click(), + ]); + return { + body: request.postDataJSON() as ApprovalResumeBody, + response, + }; +} + +async function expectCompletedApprovalToolOutput(page: Page, toolCallId: string, output: string) { + const view = messagesView(page); + const groupToggle = view.getByRole('button', { name: /^Used \d+ tools$/ }).last(); + const toolCall = view.locator(`[data-testid="tool-call"][data-tool-call-id="${toolCallId}"]`); + + // On reload, the conversation arrives asynchronously and multi-tool groups + // start collapsed. Wait for either the target card or its group before + // deciding whether expansion is necessary. + await expect(toolCall.or(groupToggle).first()).toBeVisible({ timeout: 30000 }); + if ( + !(await toolCall.isVisible()) && + (await groupToggle.getAttribute('aria-expanded')) !== 'true' + ) { + await groupToggle.click(); + } + + await expect(toolCall).toBeVisible({ timeout: 30000 }); + const toggle = toolCall.getByRole('button', { name: /Ran approval_probe/ }); + await expect(toggle).toBeVisible({ timeout: 30000 }); + if ((await toggle.getAttribute('aria-expanded')) !== 'true') { + await toggle.click(); + } + + // Scope exact output to its stable call id. This catches both a dropped + // completion and an output accidentally attached to a sibling tool card. + await expect( + view.locator(`[data-tool-call-output-id="${toolCallId}"]`).getByText(output, { exact: true }), + ).toBeVisible({ timeout: 30000 }); + // The final model turn is the quiescence barrier: all parallel tool work + // has settled before invocation-count assertions inspect the audit. + await expect(view.getByText(/^E2E approval outcomes:/).last()).toBeVisible({ timeout: 30000 }); +} + +test.describe('tool approvals', () => { + test('approves a paused tool with its original arguments', async ({ page }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + let agentId: string | undefined; + clearApprovalInvocations(originalValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + const card = await startApproval(page, label); + + await expect(card.getByRole('button', { name: 'Approve' })).toBeVisible(); + await expect(card.getByRole('button', { name: 'Reject' })).toBeVisible(); + await expect(card.getByRole('button', { name: 'Edit' })).toBeVisible(); + await expect(card.getByRole('button', { name: 'Respond' })).toBeVisible(); + + const submit = card.getByRole('button', { name: 'Submit' }); + await expect(submit).toBeDisabled(); + await card.getByRole('button', { name: 'Approve' }).click(); + await expect(submit).toBeEnabled(); + + const conversationId = new URL(page.url()).pathname.replace('/c/', ''); + const { body, response } = await submitAndCapture(page, submit); + expect(body.actionId).toBeTruthy(); + expect(body.agent_id).toBe(agentId); + expect(body.conversationId).toBe(conversationId); + expect(body.endpoint).toBe('agents'); + expect(body.decisions).toEqual([ + expect.objectContaining({ + decision: 'approve', + tool_call_id: toolCallId, + }), + ]); + await expect(response.json() as Promise).resolves.toEqual( + expect.objectContaining({ + conversationId, + status: 'resuming', + streamId: conversationId, + }), + ); + + await expectCompletedApprovalToolOutput( + page, + toolCallId, + `E2E approval probe executed: ${originalValue}`, + ); + await expectApprovalInvocationCount(originalValue, 1); + await expect(approvalCards(page)).toHaveCount(0); + } finally { + clearApprovalInvocations(originalValue); + await cleanupAgent(page, agentId); + } + }); + + test('rejects with an optional reason without executing the tool', async ({ page }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + const reason = `do not run ${label}`; + let agentId: string | undefined; + clearApprovalInvocations(originalValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + const card = await startApproval(page, label); + const submit = card.getByRole('button', { name: 'Submit' }); + + await card.getByRole('button', { name: 'Reject' }).click(); + await card.getByRole('textbox', { name: 'Reject' }).fill(` ${reason} `); + await expect(submit).toBeEnabled(); + + const { body } = await submitAndCapture(page, submit); + expect(body.decisions).toEqual([ + expect.objectContaining({ + decision: 'reject', + reason, + tool_call_id: toolCallId, + }), + ]); + + await expectCompletedApprovalToolOutput(page, toolCallId, `Blocked: ${reason}`); + await expectApprovalInvocationCount(originalValue, 0); + await expect(approvalCards(page)).toHaveCount(0); + } finally { + clearApprovalInvocations(originalValue); + await cleanupAgent(page, agentId); + } + }); + + test('requires edited arguments to be a JSON object and executes only the edit', async ({ + page, + }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + const editedValue = `edited-${label}`; + let agentId: string | undefined; + clearApprovalInvocations(originalValue, editedValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + const card = await startApproval(page, label); + const submit = card.getByRole('button', { name: 'Submit' }); + + await card.getByRole('button', { name: 'Edit' }).click(); + const editor = card.getByRole('textbox', { name: 'Edit' }); + await expect(editor).toHaveValue(new RegExp(`original-${label}`)); + + for (const invalid of ['{', 'null', '[]', '"text"']) { + await editor.fill(invalid); + await expect(card.getByText('Invalid JSON')).toBeVisible(); + await expect(submit).toBeDisabled(); + } + + await editor.fill(JSON.stringify({ value: editedValue })); + await expect(card.getByText('Invalid JSON')).toHaveCount(0); + await expect(submit).toBeEnabled(); + + const { body } = await submitAndCapture(page, submit); + expect(body.decisions).toEqual([ + expect.objectContaining({ + decision: 'edit', + editedArguments: { value: editedValue }, + tool_call_id: toolCallId, + }), + ]); + + await expectCompletedApprovalToolOutput( + page, + toolCallId, + `E2E approval probe executed: ${editedValue}`, + ); + await expectApprovalInvocationCount(editedValue, 1); + await expectApprovalInvocationCount(originalValue, 0); + } finally { + clearApprovalInvocations(originalValue, editedValue); + await cleanupAgent(page, agentId); + } + }); + + test('requires a nonblank substitute response and skips tool execution', async ({ page }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + const responseText = `manual result ${label}`; + let agentId: string | undefined; + clearApprovalInvocations(originalValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + const card = await startApproval(page, label); + const submit = card.getByRole('button', { name: 'Submit' }); + + await card.getByRole('button', { name: 'Respond' }).click(); + const responseInput = card.getByRole('textbox', { name: 'Respond' }); + await responseInput.fill(' '); + await expect(submit).toBeDisabled(); + await responseInput.fill(` ${responseText} `); + await expect(submit).toBeEnabled(); + + const { body } = await submitAndCapture(page, submit); + expect(body.decisions).toEqual([ + expect.objectContaining({ + decision: 'respond', + responseText, + tool_call_id: toolCallId, + }), + ]); + + await expectCompletedApprovalToolOutput(page, toolCallId, responseText); + await expectApprovalInvocationCount(originalValue, 0); + } finally { + clearApprovalInvocations(originalValue); + await cleanupAgent(page, agentId); + } + }); + + test('honors a hook-restricted decision set', async ({ page }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + let agentId: string | undefined; + clearApprovalInvocations(originalValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + const card = await startApproval( + page, + label, + RESTRICTED_APPROVAL_PROMPT_MARKER, + APPROVAL_REASON, + ); + + await expect(card.getByRole('button', { name: 'Approve' })).toBeVisible(); + await expect(card.getByRole('button', { name: 'Reject' })).toBeVisible(); + await expect(card.getByRole('button', { name: 'Edit' })).toHaveCount(0); + await expect(card.getByRole('button', { name: 'Respond' })).toHaveCount(0); + + const submit = card.getByRole('button', { name: 'Submit' }); + await card.getByRole('button', { name: 'Approve' }).click(); + const { body } = await submitAndCapture(page, submit); + expect(body.decisions).toEqual([ + expect.objectContaining({ + decision: 'approve', + tool_call_id: toolCallId, + }), + ]); + await expectCompletedApprovalToolOutput( + page, + toolCallId, + `E2E approval probe executed: ${originalValue}`, + ); + await expectApprovalInvocationCount(originalValue, 1); + } finally { + clearApprovalInvocations(originalValue); + await cleanupAgent(page, agentId); + } + }); + + test('reviews and approves the authoritative hook-rewritten arguments', async ({ page }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + const rewrittenValue = `rewritten-${label}`; + let agentId: string | undefined; + clearApprovalInvocations(originalValue, rewrittenValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + const card = await startApproval( + page, + label, + REWRITTEN_APPROVAL_PROMPT_MARKER, + APPROVAL_REASON, + ); + + await card.getByRole('button', { name: 'Edit' }).click(); + const editor = card.getByRole('textbox', { name: 'Edit' }); + await expect(editor).toHaveValue(new RegExp(`rewritten-${label}`)); + await expect(editor).not.toHaveValue(new RegExp(`original-${label}`)); + + await card.getByRole('button', { name: 'Edit' }).click(); + const submit = card.getByRole('button', { name: 'Submit' }); + await card.getByRole('button', { name: 'Approve' }).click(); + const { body } = await submitAndCapture(page, submit); + expect(body.decisions).toEqual([ + expect.objectContaining({ + decision: 'approve', + tool_call_id: toolCallId, + }), + ]); + + await expectCompletedApprovalToolOutput( + page, + toolCallId, + `E2E approval probe executed: ${rewrittenValue}`, + ); + await expectApprovalInvocationCount(rewrittenValue, 1); + await expectApprovalInvocationCount(originalValue, 0); + } finally { + clearApprovalInvocations(originalValue, rewrittenValue); + await cleanupAgent(page, agentId); + } + }); + + test('submits a mixed batch once and preserves decisions through collapse', async ({ page }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const firstCallId = `call_e2e_approval_${label}_first`; + const secondCallId = `call_e2e_approval_${label}_second`; + const firstValue = `first-${label}`; + const secondValue = `second-${label}`; + const responseText = `manual batch result ${label}`; + let agentId: string | undefined; + clearApprovalInvocations(firstValue, secondValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + await startApproval(page, label, BATCH_APPROVAL_PROMPT_MARKER); + const conversationPath = new URL(page.url()).pathname; + await expect(approvalCards(page)).toHaveCount(2); + + // Reconstruct both pending cards from persisted state before making any + // decisions, not just the simpler one-call resume path. + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect.poll(() => new URL(page.url()).pathname).toBe(conversationPath); + await expect(approvalCards(page)).toHaveCount(2); + + const firstCard = approvalCard(page, firstCallId); + const secondCard = approvalCard(page, secondCallId); + const submit = messagesView(page).getByRole('button', { + name: 'Submit 2 decisions', + exact: true, + }); + + await secondCard.getByRole('button', { name: 'Respond' }).click(); + await secondCard.getByRole('textbox', { name: 'Respond' }).fill(responseText); + await expect(submit).toBeDisabled(); + await firstCard.getByRole('button', { name: 'Approve' }).click(); + await expect(submit).toBeEnabled(); + + const groupToggle = messagesView(page).getByRole('button', { + name: 'Used 2 tools', + exact: true, + }); + const groupPanel = messagesView(page).getByTestId('tool-call-group-panel').last(); + await Promise.all([ + groupPanel.evaluate( + (element) => + new Promise((resolve) => { + const handleTransitionEnd = (event: Event) => { + if ( + event.target === element && + (event as TransitionEvent).propertyName === 'grid-template-rows' + ) { + element.removeEventListener('transitionend', handleTransitionEnd); + resolve(); + } + }; + element.addEventListener('transitionend', handleTransitionEnd); + }), + ), + groupToggle.click(), + ]); + await expect(groupToggle).toHaveAttribute('aria-expanded', 'false'); + await groupToggle.click(); + await expect(groupToggle).toHaveAttribute('aria-expanded', 'true'); + + const reopenedFirstCard = approvalCard(page, firstCallId); + const reopenedSecondCard = approvalCard(page, secondCallId); + await expect(reopenedFirstCard.getByRole('button', { name: 'Approve' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(reopenedSecondCard.getByRole('button', { name: 'Respond' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(reopenedSecondCard.getByRole('textbox', { name: 'Respond' })).toHaveValue( + responseText, + ); + await expect(submit).toBeEnabled(); + + const { body } = await submitAndCapture(page, submit); + expect(body.decisions).toHaveLength(2); + expect(body.decisions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + decision: 'approve', + tool_call_id: firstCallId, + }), + expect.objectContaining({ + decision: 'respond', + responseText, + tool_call_id: secondCallId, + }), + ]), + ); + + await expectCompletedApprovalToolOutput( + page, + firstCallId, + `E2E approval probe executed: ${firstValue}`, + ); + await expectCompletedApprovalToolOutput(page, secondCallId, responseText); + await expectApprovalInvocationCount(firstValue, 1); + await expectApprovalInvocationCount(secondValue, 0); + await expect(approvalCards(page)).toHaveCount(0); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect.poll(() => new URL(page.url()).pathname).toBe(conversationPath); + await expectCompletedApprovalToolOutput( + page, + firstCallId, + `E2E approval probe executed: ${firstValue}`, + ); + await expectCompletedApprovalToolOutput(page, secondCallId, responseText); + await expectApprovalInvocationCount(firstValue, 1); + await expectApprovalInvocationCount(secondValue, 0); + await expect(approvalCards(page)).toHaveCount(0); + } finally { + clearApprovalInvocations(firstValue, secondValue); + await cleanupAgent(page, agentId); + } + }); + + test('rehydrates a paused approval and its completed result across reloads', async ({ page }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + const executedText = `E2E approval probe executed: ${originalValue}`; + let agentId: string | undefined; + clearApprovalInvocations(originalValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + await startApproval(page, label); + const conversationPath = new URL(page.url()).pathname; + + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect.poll(() => new URL(page.url()).pathname).toBe(conversationPath); + const rehydratedCard = approvalCard(page, toolCallId); + await expect(rehydratedCard).toBeVisible({ timeout: 30000 }); + await expect(rehydratedCard).toContainText(APPROVAL_REASON); + + await page.goto(NEW_CHAT_PATH, { waitUntil: 'domcontentloaded' }); + await expect(approvalCards(page)).toHaveCount(0); + await page.goto(conversationPath, { waitUntil: 'domcontentloaded' }); + const navigatedCard = approvalCard(page, toolCallId); + await expect(navigatedCard).toBeVisible({ timeout: 30000 }); + await expect(navigatedCard).toContainText(APPROVAL_REASON); + + await navigatedCard.getByRole('button', { name: 'Approve' }).click(); + await submitAndCapture(page, navigatedCard.getByRole('button', { name: 'Submit' })); + await expectCompletedApprovalToolOutput(page, toolCallId, executedText); + await expectApprovalInvocationCount(originalValue, 1); + await expect(approvalCards(page)).toHaveCount(0); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect.poll(() => new URL(page.url()).pathname).toBe(conversationPath); + await expectCompletedApprovalToolOutput(page, toolCallId, executedText); + await expectApprovalInvocationCount(originalValue, 1); + await expect(approvalCards(page)).toHaveCount(0); + } finally { + clearApprovalInvocations(originalValue); + await cleanupAgent(page, agentId); + } + }); + + test('sends only one resume request for two synchronous submit clicks', async ({ page }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + const executedText = `E2E approval probe executed: ${originalValue}`; + let agentId: string | undefined; + let releaseResume = () => undefined; + let resumeHandler: ((route: Route) => Promise) | undefined; + clearApprovalInvocations(originalValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + const card = await startApproval(page, label); + const submit = card.getByRole('button', { name: 'Submit' }); + await card.getByRole('button', { name: 'Approve' }).click(); + await expect(submit).toBeEnabled(); + + let resumeRequests = 0; + const resumeGate = new Promise((resolve) => { + releaseResume = resolve; + }); + resumeHandler = async (route) => { + resumeRequests++; + if (resumeRequests === 1) { + await resumeGate; + await route.continue(); + return; + } + await route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ message: 'duplicate resume request' }), + }); + }; + await page.route('**/api/agents/chat/resume', resumeHandler); + + await submit.evaluate((button: HTMLButtonElement) => { + button.click(); + button.click(); + }); + await page.waitForTimeout(250); + expect(resumeRequests).toBe(1); + await expect(card.getByRole('button', { name: 'Submitting' })).toBeDisabled(); + await expect(card.getByRole('button', { name: 'Approve' })).toBeDisabled(); + releaseResume(); + + await expectCompletedApprovalToolOutput(page, toolCallId, executedText); + await expectApprovalInvocationCount(originalValue, 1); + await expect(approvalCards(page)).toHaveCount(0); + } finally { + releaseResume(); + if (resumeHandler) { + await page.unroute('**/api/agents/chat/resume', resumeHandler); + } + clearApprovalInvocations(originalValue); + await cleanupAgent(page, agentId); + } + }); + + test('preserves a decision after a transient resume error and retries successfully', async ({ + page, + }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + const responseText = `retry response ${label}`; + let agentId: string | undefined; + let resumeHandler: ((route: Route) => Promise) | undefined; + clearApprovalInvocations(originalValue); + + try { + agentId = await createAndSelectApprovalAgent(page); + const card = await startApproval(page, label); + const submit = card.getByRole('button', { name: 'Submit' }); + await card.getByRole('button', { name: 'Respond' }).click(); + const responseInput = card.getByRole('textbox', { name: 'Respond' }); + await responseInput.fill(responseText); + + let resumeRequests = 0; + resumeHandler = async (route) => { + resumeRequests++; + if (resumeRequests === 1) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'temporary e2e failure' }), + }); + return; + } + await route.continue(); + }; + await page.route('**/api/agents/chat/resume', resumeHandler); + + await Promise.all([ + page.waitForResponse( + (response) => isResumeRequest(response.request()) && response.status() === 500, + ), + submit.click(), + ]); + await expect(card.getByText(APPROVAL_ERROR, { exact: true })).toBeVisible(); + await expect(card.getByRole('button', { name: 'Respond' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(responseInput).toHaveValue(responseText); + await expect(submit).toBeEnabled(); + + await Promise.all([ + page.waitForResponse( + (response) => isResumeRequest(response.request()) && response.status() === 200, + ), + submit.click(), + ]); + await expectCompletedApprovalToolOutput(page, toolCallId, responseText); + await expectApprovalInvocationCount(originalValue, 0); + expect(resumeRequests).toBe(2); + await expect(approvalCards(page)).toHaveCount(0); + } finally { + if (resumeHandler) { + await page.unroute('**/api/agents/chat/resume', resumeHandler); + } + clearApprovalInvocations(originalValue); + await cleanupAgent(page, agentId); + } + }); + + test('locks the approval controls and explains an expired resume action', async ({ page }) => { + test.setTimeout(120000); + const label = uniqueLabel(); + const toolCallId = `call_e2e_approval_${label}`; + const originalValue = `original-${label}`; + const executedText = `E2E approval probe executed: ${originalValue}`; + let agentId: string | undefined; + let capturedResumeBody: Record | undefined; + let backendResolved = false; + let routeInstalled = false; + clearApprovalInvocations(originalValue); + const resumeHandler = async (route: Route) => { + capturedResumeBody = route.request().postDataJSON() as Record; + await route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ message: 'expired e2e action' }), + }); + }; + + try { + agentId = await createAndSelectApprovalAgent(page); + const card = await startApproval(page, label); + const approve = card.getByRole('button', { name: 'Approve' }); + const submit = card.getByRole('button', { name: 'Submit' }); + await approve.click(); + await page.route('**/api/agents/chat/resume', resumeHandler); + routeInstalled = true; + + await Promise.all([ + page.waitForResponse( + (response) => isResumeRequest(response.request()) && response.status() === 409, + ), + submit.click(), + ]); + await expect(card.getByText(APPROVAL_EXPIRED, { exact: true })).toBeVisible(); + await expect(approve).toHaveAttribute('aria-pressed', 'true'); + await expect(approve).toBeDisabled(); + await expect(card.getByRole('button', { name: 'Reject' })).toBeDisabled(); + await expect(card.getByRole('button', { name: 'Edit' })).toBeDisabled(); + await expect(card.getByRole('button', { name: 'Respond' })).toBeDisabled(); + await expect(submit).toBeDisabled(); + expect(capturedResumeBody).toBeDefined(); + + await page.unroute('**/api/agents/chat/resume', resumeHandler); + routeInstalled = false; + const token = await getAccessToken(page); + await requestJson(page, { + path: '/api/agents/chat/resume', + token, + method: 'POST', + body: capturedResumeBody, + }); + backendResolved = true; + await expectCompletedApprovalToolOutput(page, toolCallId, executedText); + await expectApprovalInvocationCount(originalValue, 1); + await expect(approvalCards(page)).toHaveCount(0); + } finally { + if (routeInstalled) { + await page.unroute('**/api/agents/chat/resume', resumeHandler); + } + if (!backendResolved && capturedResumeBody) { + const token = await getAccessToken(page); + await requestJson(page, { + path: '/api/agents/chat/resume', + token, + method: 'POST', + body: capturedResumeBody, + }).catch(() => undefined); + } + clearApprovalInvocations(originalValue); + await cleanupAgent(page, agentId); + } + }); +}); diff --git a/package-lock.json b/package-lock.json index cdfead3fe2..1b22ecf2d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.68", + "@librechat/agents": "^3.3.2", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -117,7 +117,7 @@ "mime": "^3.0.0", "module-alias": "^2.2.3", "mongodb": "^6.14.2", - "mongoose": "^8.23.1", + "mongoose": "^8.24.1", "multer": "^2.2.0", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", @@ -152,7 +152,7 @@ "@types/sanitize-html": "^2.13.0", "jest": "^30.2.0", "mongodb-memory-server": "^11.0.1", - "nodemon": "^3.0.3", + "nodemon": "^3.1.14", "supertest": "^7.1.0" } }, @@ -1050,7 +1050,7 @@ "jest-environment-jsdom": "^30.2.0", "jest-file-loader": "^1.0.3", "jest-junit": "^17.0.0", - "postcss": "^8.4.31", + "postcss": "^8.5.18", "postcss-preset-env": "^11.2.0", "tailwindcss": "^3.4.1", "typescript": "^5.9.3", @@ -1224,9 +1224,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.103.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.103.0.tgz", - "integrity": "sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==", + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz", + "integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1", @@ -1581,20 +1581,18 @@ } }, "node_modules/@aws-sdk/client-bedrock-agent-runtime": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent-runtime/-/client-bedrock-agent-runtime-3.1075.0.tgz", - "integrity": "sha512-gjXKDIadqv9u+VZQYV7b2aGSmGYrgE7A6W1x8AmCQYqYqp6LAGgul2ZXTfChqifsv+oYuE7aE2yN7DaRHKIbZA==", + "version": "3.1095.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent-runtime/-/client-bedrock-agent-runtime-3.1095.0.tgz", + "integrity": "sha512-/wRPU+Mjs042fDtQykL39441CiWLc++15vAFGbzf+Hek1G4aNF2RJtIJ69pxQviNNIze65L+o76OI6LR0oqyLg==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-node": "^3.972.58", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/credential-provider-node": "^3.972.72", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1602,41 +1600,22 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1075.0.tgz", - "integrity": "sha512-LDGtNMOxnMz0dw9q+8z0f/X+Soj8OyiYg5zPcqToLh6H9/HHlazogFj7PXqFLOhnvhCqyAvKVAC1ZrL0RX418g==", + "version": "3.1095.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1095.0.tgz", + "integrity": "sha512-DWcwoQdQPrQJxnG3hz1sG88EjfzGv3SReRy4mtAO+pZXtLX+trriTa20o+qcTmVzwqS43JXtX26ythOzErev9A==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-node": "^3.972.58", - "@aws-sdk/eventstream-handler-node": "^3.972.22", - "@aws-sdk/middleware-eventstream": "^3.972.18", - "@aws-sdk/middleware-websocket": "^3.972.31", - "@aws-sdk/token-providers": "3.1075.0", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1075.0.tgz", - "integrity": "sha512-SsunyegDXq68TaN5Iut8ElErGIAA6DeuKPKd5/v0lpSmZBI7ZKOC5OALyi1MRHsl/cuO/zHkJL3vKnNHdGaI+Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/credential-provider-node": "^3.972.72", + "@aws-sdk/eventstream-handler-node": "^3.972.30", + "@aws-sdk/middleware-eventstream": "^3.972.25", + "@aws-sdk/middleware-websocket": "^3.972.43", + "@aws-sdk/token-providers": "3.1095.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1829,20 +1808,18 @@ } }, "node_modules/@aws-sdk/client-kendra": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-kendra/-/client-kendra-3.1075.0.tgz", - "integrity": "sha512-tgTRI965x8K/QT8LuTDA2FMd85UmQDpSs1MA3NM/T5KAyQ7+OXUalEFohLp+4GtefYxwZtEP3E2xEoENv2YG+A==", + "version": "3.1095.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kendra/-/client-kendra-3.1095.0.tgz", + "integrity": "sha512-Ty3QbO46zzBfG3lbdeybFNZAGcNDnD8Ni5VV5rri5ez2EOlp/B0LMlNtAgWFORb6o9oaOQWpyLbB77ksSXzA7w==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-node": "^3.972.58", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/credential-provider-node": "^3.972.72", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2022,17 +1999,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", - "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", + "version": "3.977.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.1.tgz", + "integrity": "sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@aws-sdk/xml-builder": "^3.972.31", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.8", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -2040,6 +2017,15 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/core/node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/crc64-nvme": { "version": "3.972.5", "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", @@ -2070,15 +2056,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.49", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz", - "integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==", + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.61.tgz", + "integrity": "sha512-qihs2ekMb89Nxd2JenCgVFhjbkb3EIo7HEBCBzyZACKVJdrLUZBLOmAE3xr0Sayml8n/jZSzwO/IufIiIzO7PQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2086,17 +2072,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz", - "integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.63.tgz", + "integrity": "sha512-yfozsS8wkWZEi/n6IsrodcFKBWZ0iNAezhJbTReMNc0z1Px17qdeAeuL1/wziCAmCZyXiW7QzP75ggJkBQv8jQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2104,23 +2090,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.56", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz", - "integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.6.tgz", + "integrity": "sha512-jGLTW1bj148GL/6/IMlfY2fMYS9FtHOG+NahkFD4y0qkzYudNUahelxryY68/HGMslYuHClk1XaS/3b3eJzEkg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-env": "^3.972.49", - "@aws-sdk/credential-provider-http": "^3.972.51", - "@aws-sdk/credential-provider-login": "^3.972.55", - "@aws-sdk/credential-provider-process": "^3.972.49", - "@aws-sdk/credential-provider-sso": "^3.972.55", - "@aws-sdk/credential-provider-web-identity": "^3.972.55", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/credential-provider-env": "^3.972.61", + "@aws-sdk/credential-provider-http": "^3.972.63", + "@aws-sdk/credential-provider-login": "^3.972.68", + "@aws-sdk/credential-provider-process": "^3.972.61", + "@aws-sdk/credential-provider-sso": "^3.973.5", + "@aws-sdk/credential-provider-web-identity": "^3.972.67", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2128,16 +2114,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz", - "integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.68.tgz", + "integrity": "sha512-w6tNci6g7RqFpLhj1f5xseBvaNojb4Pkgp5Jp5apl9hrJtaf2AA+rX9+qlhlWUK6kcyAFYPA7emO+55zj+S98Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2145,21 +2131,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.58", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz", - "integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==", + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.72.tgz", + "integrity": "sha512-blQ7F5QGzylnzeh5549zQLoCAiMHkXFLjFovEMaVy4b2X8JhUu+u9NXro1hyK95YHdVFNmBHKs2hIHtZchxKlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.49", - "@aws-sdk/credential-provider-http": "^3.972.51", - "@aws-sdk/credential-provider-ini": "^3.972.56", - "@aws-sdk/credential-provider-process": "^3.972.49", - "@aws-sdk/credential-provider-sso": "^3.972.55", - "@aws-sdk/credential-provider-web-identity": "^3.972.55", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/credential-provider-env": "^3.972.61", + "@aws-sdk/credential-provider-http": "^3.972.63", + "@aws-sdk/credential-provider-ini": "^3.973.6", + "@aws-sdk/credential-provider-process": "^3.972.61", + "@aws-sdk/credential-provider-sso": "^3.973.5", + "@aws-sdk/credential-provider-web-identity": "^3.972.67", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2167,15 +2153,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.49", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz", - "integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==", + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.61.tgz", + "integrity": "sha512-xzRuj+fUVO4nkafKQJVKAF97kGpeQbfjuwmRrtGZNf42/1dkmcz6o7dswBy7alY0htQn5sCL1GWQYEykviWZkA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2183,17 +2169,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz", - "integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==", + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.5.tgz", + "integrity": "sha512-fZRjjWhLFelsDoOYjqShQTrIGYC3Pf9Mx9Czf+1ikfQDgktxjze33dVo1q1/ZQ+T0qbtejVoHNHrfD5aJVpv/w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/token-providers": "3.1074.0", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/token-providers": "3.1095.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2201,16 +2187,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz", - "integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.67.tgz", + "integrity": "sha512-FTNZ05gkPBA6CKbU3N4zPgybV+stdazwMOya75CmGdcJL7p8Fw/BdHP8WVxJd0mvzyPK2cg/C3gli58Ir4HgCw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2249,14 +2235,14 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.22.tgz", - "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.30.tgz", + "integrity": "sha512-hJboPgIpq5+ADc++/B9TBqn65CXV21cZLGB8V5RBQbxkZ/rQ6qMfcxTnW/SvQlasX4jhaSG8B1wsVjhQyDrsnQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2294,14 +2280,14 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.18.tgz", - "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.25.tgz", + "integrity": "sha512-9SFbPzJDHHR5k6Q6KvXVas/veUm/TzNcNTFM2UhdXHZHpyIvI2lS+s4cxljw1BihGpVhsAkQDo/2nW7dHxpf4Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2593,17 +2579,17 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.31.tgz", - "integrity": "sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.43.tgz", + "integrity": "sha512-n29++15Vma64Kd0enp9Bo8a6LTm8TvUoMbJEwqXtIksv0oEs+SUCRMm3gozDfPD1Ly0k/sSBughxarlLlRF6Xw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2611,20 +2597,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz", - "integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==", + "version": "3.997.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.35.tgz", + "integrity": "sha512-2MJfseVG/aXvIyOIBlYA/Oaf6qFDdsu4D8RKsEUdOQpVuLaor0BdxIBBtJLBNQQEe6Ku3YMvLljwb1MwVUpzRw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/signature-v4-multi-region": "^3.996.35", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2632,14 +2616,14 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", - "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "version": "3.996.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz", + "integrity": "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2725,16 +2709,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1074.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz", - "integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==", + "version": "3.1095.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1095.0.tgz", + "integrity": "sha512-65SudS6y4nzaYHybtqcpm3sHe5jLhdMn68HRKS1nUx690BtQeaAQOoujQ+dpOjBATIVGVgKKjEP8tR+U06QJQA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2742,12 +2726,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", - "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2859,12 +2843,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", - "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -10053,19 +10037,19 @@ } }, "node_modules/@langchain/anthropic": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.1.tgz", - "integrity": "sha512-j92zCCd5BFH3rHMRzc2wBmSKDoVpinof1oh8aFiAz9TWbSOc4tGU4n6bqwy/wP0GH1uO96zZHLGCHBMPgrxTNw==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.2.tgz", + "integrity": "sha512-lYOHo5BpRbgmQVSggwPLhBNFtatiAFlVirY44tnfocx5tQKfeLYo5emqPwNwcNBuw236sr0tsDxZcmbLASN/GA==", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.103.0", + "@anthropic-ai/sdk": "^0.115.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.1" + "@langchain/core": "^1.2.3" } }, "node_modules/@langchain/anthropic/node_modules/zod": { @@ -10078,15 +10062,15 @@ } }, "node_modules/@langchain/aws": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@langchain/aws/-/aws-1.4.2.tgz", - "integrity": "sha512-QpgB7ogkPxHICXezWQx5GkwulCK5HvbsCEkWLMK3VRHaibHC9UMxJCI/78XDXSsGU6EDMmRXjbNR+2q0kxx++Q==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@langchain/aws/-/aws-1.4.3.tgz", + "integrity": "sha512-X3oNXI1/pLizW6D4Wd1ojWTTPGnRgli8S+rm+CAhFAevIP3UdFfTDY6xih7xrQQ/0sO7po/eD0jHeZoLRKhhBg==", "license": "MIT", "dependencies": { - "@aws-sdk/client-bedrock-agent-runtime": "^3.1059.0", - "@aws-sdk/client-bedrock-runtime": "^3.1059.0", - "@aws-sdk/client-kendra": "^3.1059.0", - "@aws-sdk/credential-provider-node": "^3.972.49" + "@aws-sdk/client-bedrock-agent-runtime": "^3.1078.0", + "@aws-sdk/client-bedrock-runtime": "^3.1078.0", + "@aws-sdk/client-kendra": "^3.1078.0", + "@aws-sdk/credential-provider-node": "^3.972.61" }, "engines": { "node": ">=20" @@ -10096,9 +10080,9 @@ } }, "node_modules/@langchain/core": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.2.tgz", - "integrity": "sha512-KfjEOT6sCg0vvItagfEtGpmrGoLMGfma4Affb5BGEqPmS2YR3AxW54pABSkhQlzCehTB+0BnLquAe1lGF4J9zQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz", + "integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", @@ -10123,12 +10107,12 @@ } }, "node_modules/@langchain/deepseek": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@langchain/deepseek/-/deepseek-1.1.3.tgz", - "integrity": "sha512-2fQwIQ7OLKY/WceTaZ/dJN4p+EzDiTqvR/0RG2rm0Y6GLlxXgVlBb5qK3l+UfqmU68FgexbhLZkgUnC72THnww==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@langchain/deepseek/-/deepseek-1.1.5.tgz", + "integrity": "sha512-5IRoEUaHAgIF8TyIncNVhhjavCqsjWTjakWsnus1yJN2X3W15Bw8Qmf+vJzCnFo7yndICsGdOGfHJoIN5xNxoQ==", "license": "MIT", "dependencies": { - "@langchain/openai": "1.5.3" + "@langchain/openai": "1.5.5" }, "engines": { "node": ">=20" @@ -10163,9 +10147,9 @@ } }, "node_modules/@langchain/google-gauth/node_modules/gaxios": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", - "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", + "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -10191,9 +10175,9 @@ } }, "node_modules/@langchain/google-gauth/node_modules/google-auth-library": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", - "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -10431,9 +10415,9 @@ } }, "node_modules/@langchain/openai": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.3.tgz", - "integrity": "sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.5.tgz", + "integrity": "sha512-wX7dwb9z4nf5FHXlIl/X2mk08pzonvRHCt1D4+s1zXLP0duYDC95j7dulPIQJ6fmhbyYQc9Ki8mEhY/D1lB8kw==", "license": "MIT", "dependencies": { "js-tiktoken": "^1.0.12", @@ -10444,13 +10428,13 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.1" + "@langchain/core": "^1.2.2" } }, "node_modules/@langchain/openai/node_modules/openai": { - "version": "6.45.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", - "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", @@ -10508,12 +10492,12 @@ } }, "node_modules/@langchain/xai": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@langchain/xai/-/xai-1.4.3.tgz", - "integrity": "sha512-eh8DL6x9zbjw2QlyvCFlC2r3duq1CnADHh2cxsrpmWuo2vFgqh8dOxHN2lDqjHZC2o5y94/TIhKhE7QF/IyDFQ==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@langchain/xai/-/xai-1.4.5.tgz", + "integrity": "sha512-w5emVjqpguoNHO6rYOWsSIRAWscpN5N3THfe85wseWjrHMaNTto/P8eDQR7zg4M5Z3MmHshNrmSjPQiR+RS4eQ==", "license": "MIT", "dependencies": { - "@langchain/openai": "1.5.3" + "@langchain/openai": "1.5.5" }, "engines": { "node": ">=20" @@ -10523,22 +10507,22 @@ } }, "node_modules/@langfuse/core": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@langfuse/core/-/core-5.7.0.tgz", - "integrity": "sha512-kLFYagw3Js5QDesQbMLOzV4L4/0hYndSmAX6em6zDCmA4qMKfdRJMZ5F/fpl9LONFMTu7pU6WGLyUxM80hjYVQ==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@langfuse/core/-/core-5.9.1.tgz", + "integrity": "sha512-KvyAskAO+2ixJwr9wy148ttR/Zn3oapvOJ2Br4Xcp6zhDpjSIIGB4jW/jkp53/KU9MyEHj0RSFPK/yKoxLC4KA==", "license": "MIT", "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "node_modules/@langfuse/langchain": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@langfuse/langchain/-/langchain-5.7.0.tgz", - "integrity": "sha512-bALp9DmuXgTgmiYpzuOi+eLlZcZ5gc8iD/gwXx/wS4Vqv6hZjydRyQBKPPLe6nhpCexIXtXowwlmffwMQ9/Ggg==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@langfuse/langchain/-/langchain-5.9.1.tgz", + "integrity": "sha512-Qv5Wn8EO2cRiVTBlb/zQwRhjD+93rVjD02in9L0+hyg9OBvc2rzqTfr8zKVDv9pgpsDfWD7Wm/sXyo7vGZmsTA==", "license": "MIT", "dependencies": { - "@langfuse/core": "^5.7.0", - "@langfuse/tracing": "^5.7.0" + "@langfuse/core": "^5.9.1", + "@langfuse/tracing": "^5.9.1" }, "peerDependencies": { "@langchain/core": ">=0.3.8", @@ -10546,12 +10530,12 @@ } }, "node_modules/@langfuse/otel": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@langfuse/otel/-/otel-5.7.0.tgz", - "integrity": "sha512-x5HANvvDV23btbTijz9eW2DzvX/sZ4orsahYXuwsCApOzEWSgEzd0ZrpHPSz4+epytps1RBe3M6e5gm2EgaAmA==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@langfuse/otel/-/otel-5.9.1.tgz", + "integrity": "sha512-viM5Qq/AIPZPXfO7YdSmDHxEDSrNU/MyGzuE9zKA6hGBII1iLowU+qL99O+TjnXqxoADqlNibhY2pg1/WTIPcw==", "license": "MIT", "dependencies": { - "@langfuse/core": "^5.7.0" + "@langfuse/core": "^5.9.1" }, "engines": { "node": ">=20" @@ -10564,12 +10548,12 @@ } }, "node_modules/@langfuse/tracing": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@langfuse/tracing/-/tracing-5.7.0.tgz", - "integrity": "sha512-FCroWXE0510BUt2vHCEk64Wb+nywc3WVdx3+zaWyW1CI8mSa9i/yX+Oe2a956QRLPhVCeFgO27Y8Zlg7RaDWig==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@langfuse/tracing/-/tracing-5.9.1.tgz", + "integrity": "sha512-tJRyVAv1JkuOPh4Uz5eWUNH8U4jcJVtK2F5QNy5cZUzXCSrXobCSHusPbxY6VFZcLcFgtpDtSaxL7ev1tV2JNQ==", "license": "MIT", "dependencies": { - "@langfuse/core": "^5.7.0" + "@langfuse/core": "^5.9.1" }, "engines": { "node": ">=20" @@ -10630,16 +10614,16 @@ } }, "node_modules/@librechat/agents": { - "version": "3.2.68", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.2.68.tgz", - "integrity": "sha512-8yT0+kU66meD/K4wYa/QBX9FskTwU5Eq52RjNfMO5nylzsoZ2NjAzsxVewC9lhEucU0lKhrZqNU2UiqbiyIcCw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.3.2.tgz", + "integrity": "sha512-BnwdngjFgMEtjJQtVSkQq0GKt7rL2hROCXzeDkwHklSV20V3VWYBkIsLlL2Cxt3/xUTwaQT+KOPWqu8riZ9k/w==", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.103.0", + "@anthropic-ai/sdk": "^0.115.0", "@aws-sdk/client-bedrock-runtime": "^3.1075.0", - "@langchain/anthropic": "^1.5.1", + "@langchain/anthropic": "1.5.2", "@langchain/aws": "^1.4.2", - "@langchain/core": "^1.2.2", + "@langchain/core": "^1.2.3", "@langchain/deepseek": "^1.1.3", "@langchain/google-common": "2.2.0", "@langchain/google-gauth": "2.2.0", @@ -10658,7 +10642,7 @@ "@scarf/scarf": "^1.4.0", "@types/diff": "^7.0.2", "ai-tokenizer": "^1.0.6", - "axios": "^1.16.0", + "axios": "^1.18.1", "cheerio": "^1.0.0", "diff": "^9.0.0", "dotenv": "^16.4.7", @@ -10673,7 +10657,7 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@anthropic-ai/sandbox-runtime": "^0.0.54" + "@anthropic-ai/sandbox-runtime": "^0.0.67" }, "peerDependenciesMeta": { "@anthropic-ai/sandbox-runtime": { @@ -10712,23 +10696,6 @@ "node": ">=6" } }, - "node_modules/@librechat/agents/node_modules/@langchain/openai": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.5.tgz", - "integrity": "sha512-wX7dwb9z4nf5FHXlIl/X2mk08pzonvRHCt1D4+s1zXLP0duYDC95j7dulPIQJ6fmhbyYQc9Ki8mEhY/D1lB8kw==", - "license": "MIT", - "dependencies": { - "js-tiktoken": "^1.0.12", - "openai": "^6.41.0", - "zod": "^3.25.76 || ^4" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@langchain/core": "^1.2.2" - } - }, "node_modules/@librechat/agents/node_modules/@opentelemetry/api-logs": { "version": "0.220.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", @@ -10758,9 +10725,9 @@ } }, "node_modules/@librechat/agents/node_modules/@opentelemetry/context-async-hooks": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz", - "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.10.0.tgz", + "integrity": "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==", "license": "Apache-2.0", "engines": { "node": "^18.19.0 || >=20.6.0" @@ -11156,6 +11123,18 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, + "node_modules/@librechat/agents/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/context-async-hooks": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz", + "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@librechat/agents/node_modules/@opentelemetry/sdk-trace-base": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", @@ -11191,6 +11170,18 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@librechat/agents/node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/context-async-hooks": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz", + "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@librechat/agents/node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -11201,9 +11192,9 @@ } }, "node_modules/@librechat/agents/node_modules/import-in-the-middle": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz", - "integrity": "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.2.tgz", + "integrity": "sha512-jTd2FfOgOWOdgjkHuk/1Ms8VKFXkPs15ymYBETw1sAOrO/dY3XeGVRWir9qBbw7pXr0T2eTFwfCZ+N02HmiNGA==", "license": "Apache-2.0", "dependencies": { "cjs-module-lexer": "^2.2.0", @@ -11215,9 +11206,9 @@ } }, "node_modules/@librechat/agents/node_modules/openai": { - "version": "6.46.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.46.0.tgz", - "integrity": "sha512-DFg6jEPT2RO+oAyXtddeUJU8zkGy1OQ1AjGzNIJUMQG03TTqvCpy9tBpQ+2VVVnvrl3E56F8GEin2JYtWpITtA==", + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", @@ -11244,15 +11235,6 @@ } } }, - "node_modules/@librechat/agents/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@librechat/api": { "resolved": "packages/api", "link": true @@ -17969,13 +17951,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", - "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.30.0.tgz", + "integrity": "sha512-dl2yRglDxfzH9uJ4fSo4zTaAHa0zH7+V7BZMRWy8hEYIKT1BiqMUK/CN6T3ADQ3kbA5N1tmUulroJ2UtONS7Kw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.15.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -17983,13 +17964,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.1.tgz", - "integrity": "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==", + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.14.tgz", + "integrity": "sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -18067,13 +18048,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.1.tgz", - "integrity": "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==", + "version": "5.6.11", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.11.tgz", + "integrity": "sha512-o0Zkj1nKqJAoq+a+BrkhU39tRftMNjLwpc/z06Frfl43wpbHrJMaSAVZE4vTqlxtVkNaGaT0bIDxOp7tkFTuQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -18334,13 +18315,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", - "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", + "version": "4.9.11", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.11.tgz", + "integrity": "sha512-slbzbz8taEOzoXv/9y34YNBoE+ZHmddLykCgjDAjvMAsu2nM5s2Gzwa5OGF721tD8s+CKeFUBds5lSj9lcbuDg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -18426,13 +18407,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.1.tgz", - "integrity": "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==", + "version": "5.6.10", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.10.tgz", + "integrity": "sha512-EXhWePm3SXJAX38npIy4TXL2Aex/OVgCClTjelN2QHw/U+8CQUH8C7AaxsVzQcYieYPseywn48s89DmvuGjiAg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -18458,9 +18439,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", - "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -20261,29 +20242,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -21710,9 +21668,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bare-events": { "version": "2.8.2", @@ -22039,14 +22001,15 @@ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -23011,12 +22974,6 @@ "node": ">= 0.6" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -26514,16 +26471,6 @@ "minimatch": "^5.0.1" } }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/filelist/node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -27168,29 +27115,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/glob/node_modules/lru-cache": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", @@ -29663,16 +29587,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/jest/node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -33809,9 +33723,9 @@ } }, "node_modules/mongoose": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.1.tgz", - "integrity": "sha512-gHSPD8qEwRmiXapK17hEnFWZdcFENMegHTcw5XIIg2+7R8eXQvdwSiMpD/A2oG8tKzFLLHyRXd8/eaDPAVwZgQ==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.1.tgz", + "integrity": "sha512-UpHBA0l5kHyKJQFjmBaFYQFo5sgz1DK0TRqDkOyBLYbqiIbKKhIvBpHWBXqeo0rgW4kGI1UhhAw+kTQZoj1BdA==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -33917,9 +33831,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -34177,15 +34091,16 @@ } }, "node_modules/nodemon": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.3.tgz", - "integrity": "sha512-7jH/NXbFPxVaMwmBCC2B9F/V6X1VkEdNgx3iu9jji8WxWcvhMWkmhNWhI5077zknOnZnBzba9hZP6bCPJLSReQ==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", @@ -34213,6 +34128,22 @@ "node": ">=4" } }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -35332,9 +35263,9 @@ } }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -35351,7 +35282,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -38966,15 +38897,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/rimraf/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -40581,15 +40503,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/sucrase/node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -43017,29 +42930,6 @@ } } }, - "node_modules/workbox-build/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/workbox-build/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/workbox-build/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -43829,7 +43719,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.68", + "@librechat/agents": "^3.3.2", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", @@ -43868,7 +43758,7 @@ "mathjs": "^15.2.0", "memorystore": "^1.6.7", "mongodb": "^6.14.2", - "mongoose": "^8.23.1", + "mongoose": "^8.24.1", "nanoid": "^3.3.7", "node-fetch": "2.7.0", "pdfjs-dist": "^5.4.624", @@ -46254,7 +46144,7 @@ "librechat-data-provider": "*", "lodash": "^4.17.23", "meilisearch": "^0.38.0", - "mongoose": "^8.23.1", + "mongoose": "^8.24.1", "nanoid": "^3.3.7", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0" diff --git a/package.json b/package.json index 364b2e852a..7d40873ca9 100644 --- a/package.json +++ b/package.json @@ -153,11 +153,12 @@ "typescript-eslint": "^8.60.1" }, "overrides": { + "brace-expansion": "^5.0.8", "@xmldom/xmldom": "^0.8.13", "elliptic": "^6.6.1", "form-data": "^4.0.6", "langsmith": "^0.6.0", - "postcss": "^8.5.13", + "postcss": "^8.5.18", "tslib": "^2.8.1", "fast-xml-parser": "5.7.2", "serialize-javascript": "7.0.5", diff --git a/packages/api/package.json b/packages/api/package.json index 621d528d54..cde7c6993f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -116,7 +116,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.68", + "@librechat/agents": "^3.3.2", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", @@ -155,7 +155,7 @@ "mathjs": "^15.2.0", "memorystore": "^1.6.7", "mongodb": "^6.14.2", - "mongoose": "^8.23.1", + "mongoose": "^8.24.1", "nanoid": "^3.3.7", "node-fetch": "2.7.0", "pdfjs-dist": "^5.4.624", diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 83ee5f6959..95fd6f19d9 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -465,6 +465,24 @@ describe('summarizationConfig field passthrough', () => { // Suite 5: Multi-agent + per-agent overrides // --------------------------------------------------------------------------- describe('multi-agent + per-agent overrides', () => { + it('normalizes missing persisted edges before creating the SDK graph', async () => { + await createRun({ + agents: [makeAgent({ id: 'agent_1' }), makeAgent({ id: 'agent_2' })] as never, + signal: new AbortController().signal, + streaming: true, + streamUsage: true, + }); + + const createMock = Run.create as jest.Mock; + const runConfig = createMock.mock.calls[0][0] as { + graphConfig: { type: string; edges: unknown[] }; + }; + expect(runConfig.graphConfig).toMatchObject({ + type: 'multi-agent', + edges: [], + }); + }); + it('different agents get different effectiveMaxContextTokens', async () => { const agents = await callAndCapture({ agents: [ diff --git a/packages/api/src/agents/edges.spec.ts b/packages/api/src/agents/edges.spec.ts index 447c65fdb5..d612d670cb 100644 --- a/packages/api/src/agents/edges.spec.ts +++ b/packages/api/src/agents/edges.spec.ts @@ -3,11 +3,56 @@ import { getEdgeKey, getEdgeParticipants, collectEdgeAgentIds, + replaceEdgeSourceId, filterOrphanedEdges, createEdgeCollector, } from './edges'; describe('edges utilities', () => { + describe('replaceEdgeSourceId', () => { + it('should assign a newly created agent id to placeholder sources', () => { + const edges: GraphEdge[] = [ + { from: '', to: 'agent_target', edgeType: 'handoff' }, + { from: 'agent_other', to: 'agent_target', edgeType: 'handoff' }, + ]; + + expect(replaceEdgeSourceId(edges, '', 'agent_router')).toEqual([ + { from: 'agent_router', to: 'agent_target', edgeType: 'handoff' }, + { from: 'agent_other', to: 'agent_target', edgeType: 'handoff' }, + ]); + }); + + it('should rewrite copied agent ids inside multi-source edges', () => { + const edges: GraphEdge[] = [ + { + from: ['agent_original', 'agent_peer'], + to: 'agent_target', + edgeType: 'handoff', + }, + ]; + + expect(replaceEdgeSourceId(edges, 'agent_original', 'agent_clone')).toEqual([ + { + from: ['agent_clone', 'agent_peer'], + to: 'agent_target', + edgeType: 'handoff', + }, + ]); + }); + + it('should preserve untouched edge references', () => { + const edge: GraphEdge = { + from: 'agent_other', + to: 'agent_target', + edgeType: 'handoff', + }; + + const result = replaceEdgeSourceId([edge], 'agent_original', 'agent_clone'); + + expect(result?.[0]).toBe(edge); + }); + }); + describe('getEdgeKey', () => { it('should create key from simple string from/to', () => { const edge: GraphEdge = { from: 'agent_a', to: 'agent_b', edgeType: 'handoff' }; diff --git a/packages/api/src/agents/edges.ts b/packages/api/src/agents/edges.ts index 9bc3705c60..98482d214d 100644 --- a/packages/api/src/agents/edges.ts +++ b/packages/api/src/agents/edges.ts @@ -1,5 +1,38 @@ import type { GraphEdge } from 'librechat-data-provider'; +/** + * Rewrites an agent id wherever it appears as an edge source. + * + * Agent creation uses an empty source id until the server assigns the + * persisted id, while agent duplication needs to move the copied router's + * outgoing edges to the clone. Keeping both cases here makes the rewrite + * consistent for scalar and multi-source edges. + */ +export function replaceEdgeSourceId( + edges: GraphEdge[] | undefined, + previousSourceId: string, + nextSourceId: string, +): GraphEdge[] | undefined { + if (!edges?.length || previousSourceId === nextSourceId) { + return edges; + } + + return edges.map((edge) => { + if (Array.isArray(edge.from)) { + if (!edge.from.includes(previousSourceId)) { + return edge; + } + return { + ...edge, + from: edge.from.map((sourceId) => + sourceId === previousSourceId ? nextSourceId : sourceId, + ), + }; + } + return edge.from === previousSourceId ? { ...edge, from: nextSourceId } : edge; + }); +} + /** * Creates a stable key for edge deduplication. * Handles both single and array-based from/to values. diff --git a/packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts b/packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts new file mode 100644 index 0000000000..0593b3bad4 --- /dev/null +++ b/packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts @@ -0,0 +1,279 @@ +import { Constants } from '@librechat/agents'; +import { HumanMessage, ToolMessage } from '@librechat/agents/langchain/messages'; +import type { GraphEdge, IState, Run, RunConfig } from '@librechat/agents'; +import type { BaseMessage } from '@librechat/agents/langchain/messages'; +import { applyCustomHandoffPromptKeyCompatibility } from './handoffPromptKeyCompatibility'; + +type HandoffReceptionResult = { + filteredMessages: BaseMessage[]; + instructions: string | null; + sourceAgentName: string | null; + parallelSiblings: string[]; +} | null; + +type ProcessHandoffReception = (messages: BaseMessage[], agentId: string) => HandoffReceptionResult; + +type TestGraph = { + processHandoffReception: ProcessHandoffReception; +}; + +const createGraphConfig = (edges: GraphEdge[]): RunConfig['graphConfig'] => ({ + type: 'multi-agent', + agents: [], + edges, +}); + +const createRun = ( + processHandoffReception: ProcessHandoffReception, +): { run: Run; graph: TestGraph } => { + const graph: TestGraph = { processHandoffReception }; + return { + run: { Graph: graph } as unknown as Run, + graph, + }; +}; + +const findTransfer = (messages: BaseMessage[], agentId: string): ToolMessage | undefined => + messages.find( + (message): message is ToolMessage => + ToolMessage.isInstance(message) && + (message.name === `${Constants.LC_TRANSFER_TO_}${agentId}` || + (message.name === 'conditional_transfer' && + message.additional_kwargs.handoff_destination === agentId)), + ); + +/** + * Models the reception behavior in @librechat/agents 3.2.68: filtering and + * metadata work, but only the built-in Instructions/Context labels are read. + */ +const createSdkProcess = (): jest.MockedFunction => + jest.fn((messages, agentId) => { + const transfer = findTransfer(messages, agentId); + if (!transfer) { + return null; + } + + const content = + typeof transfer.content === 'string' ? transfer.content : JSON.stringify(transfer.content); + const instructions = + content.match(/(?:Instructions?|Context):\s*([\s\S]+)/i)?.[1]?.trim() ?? null; + const rawSiblings = transfer.additional_kwargs.handoff_parallel_siblings; + + return { + filteredMessages: messages.filter((message) => message !== transfer), + instructions, + sourceAgentName: + typeof transfer.additional_kwargs.handoff_source_name === 'string' + ? transfer.additional_kwargs.handoff_source_name + : null, + parallelSiblings: Array.isArray(rawSiblings) + ? rawSiblings.filter((sibling): sibling is string => typeof sibling === 'string') + : [], + }; + }); + +describe('applyCustomHandoffPromptKeyCompatibility', () => { + it('leaves multi-agent graphs without edges unpatched', () => { + const sdkProcess = createSdkProcess(); + const { run, graph } = createRun(sdkProcess); + const originalProcess = graph.processHandoffReception; + // Persisted agents can predate `edges`, even though the current SDK type requires it. + const graphConfig = { + type: 'multi-agent', + agents: [], + } as unknown as RunConfig['graphConfig']; + + expect(() => applyCustomHandoffPromptKeyCompatibility(run, graphConfig)).not.toThrow(); + expect(graph.processHandoffReception).toBe(originalProcess); + }); + + it('recovers a custom prompt key for scalar and array handoff endpoints', () => { + const sdkProcess = createSdkProcess(); + const { run, graph } = createRun(sdkProcess); + const userMessage = new HumanMessage('Delegate the audit'); + const transferMessage = new ToolMessage({ + id: 'transfer-message', + name: `${Constants.LC_TRANSFER_TO_}specialist`, + tool_call_id: 'transfer-call', + content: 'Successfully transferred to specialist\n\nWork_items: Audit cache invalidation', + status: 'success', + artifact: { preserved: true }, + metadata: { trace: 'handoff' }, + response_metadata: { provider: 'mock' }, + additional_kwargs: { + handoff_source_name: 'Router', + handoff_parallel_siblings: ['peer', 42], + }, + }); + const messages = [userMessage, transferMessage]; + + applyCustomHandoffPromptKeyCompatibility( + run, + createGraphConfig([ + { + from: ['router', 'peer'], + to: ['specialist', 'backup'], + edgeType: 'handoff', + prompt: 'Work to complete', + promptKey: 'work_items', + }, + ]), + ); + + const result = graph.processHandoffReception(messages, 'specialist'); + + expect(result).toEqual({ + filteredMessages: [userMessage], + instructions: 'Audit cache invalidation', + sourceAgentName: 'Router', + parallelSiblings: ['peer'], + }); + expect(sdkProcess).toHaveBeenCalledTimes(2); + expect(sdkProcess.mock.calls[0]?.[0]).toBe(messages); + + const retryMessages = sdkProcess.mock.calls[1]?.[0]; + const normalizedTransfer = retryMessages?.[1]; + expect(retryMessages).not.toBe(messages); + expect(normalizedTransfer).toBeInstanceOf(ToolMessage); + expect(normalizedTransfer).not.toBe(transferMessage); + expect(normalizedTransfer?.content).toBe( + 'Successfully transferred to specialist\n\nInstructions: Audit cache invalidation', + ); + expect(normalizedTransfer).toMatchObject({ + id: 'transfer-message', + name: `${Constants.LC_TRANSFER_TO_}specialist`, + tool_call_id: 'transfer-call', + status: 'success', + artifact: { preserved: true }, + metadata: { trace: 'handoff' }, + response_metadata: { provider: 'mock' }, + additional_kwargs: { + handoff_source_name: 'Router', + handoff_parallel_siblings: ['peer', 42], + }, + }); + expect(transferMessage.content).toContain('Work_items:'); + }); + + it.each([ + { + name: 'the default instructions key', + promptKey: undefined, + label: 'Instructions', + }, + { + name: 'the already-supported context key', + promptKey: 'context', + label: 'Context', + }, + ])('leaves $name on the SDK path', ({ promptKey, label }) => { + const sdkProcess = createSdkProcess(); + const { run, graph } = createRun(sdkProcess); + const originalProcess = graph.processHandoffReception; + const edge: GraphEdge = { + from: 'router', + to: 'specialist', + edgeType: 'handoff', + prompt: 'Work to complete', + ...(promptKey && { promptKey }), + }; + + applyCustomHandoffPromptKeyCompatibility(run, createGraphConfig([edge])); + + expect(graph.processHandoffReception).toBe(originalProcess); + expect( + graph.processHandoffReception( + [ + new ToolMessage({ + name: `${Constants.LC_TRANSFER_TO_}specialist`, + tool_call_id: 'transfer-call', + content: `Successfully transferred\n\n${label}: Keep the native behavior`, + }), + ], + 'specialist', + )?.instructions, + ).toBe('Keep the native behavior'); + expect(sdkProcess).toHaveBeenCalledTimes(1); + }); + + it('self-disables when the SDK already extracts a custom prompt key', () => { + const upstreamResult: Exclude = { + filteredMessages: [], + instructions: 'Handled upstream', + sourceAgentName: 'Router', + parallelSiblings: [], + }; + const sdkProcess = jest.fn< + ReturnType, + Parameters + >(() => upstreamResult); + const { run, graph } = createRun(sdkProcess); + const config = createGraphConfig([ + { + from: 'router', + to: 'specialist', + edgeType: 'handoff', + prompt: 'Work to complete', + promptKey: 'work_items', + }, + ]); + + applyCustomHandoffPromptKeyCompatibility(run, config); + const wrappedProcess = graph.processHandoffReception; + applyCustomHandoffPromptKeyCompatibility(run, config); + + expect(graph.processHandoffReception).toBe(wrappedProcess); + expect( + graph.processHandoffReception( + [ + new ToolMessage({ + name: `${Constants.LC_TRANSFER_TO_}specialist`, + tool_call_id: 'transfer-call', + content: 'Successfully transferred\n\nWork_items: Handled upstream', + }), + ], + 'specialist', + ), + ).toBe(upstreamResult); + expect(sdkProcess).toHaveBeenCalledTimes(1); + }); + + it('ignores custom keys on irrelevant destinations and direct edges', () => { + const sdkProcess = createSdkProcess(); + const { run, graph } = createRun(sdkProcess); + + applyCustomHandoffPromptKeyCompatibility( + run, + createGraphConfig([ + { + from: 'router', + to: 'different-agent', + edgeType: 'handoff', + prompt: 'Work to complete', + promptKey: 'work_items', + }, + { + from: ['router', 'peer'], + to: ['specialist', 'backup'], + edgeType: 'direct', + prompt: 'Direct prompt', + promptKey: 'work_items', + }, + ]), + ); + + const result = graph.processHandoffReception( + [ + new ToolMessage({ + name: `${Constants.LC_TRANSFER_TO_}specialist`, + tool_call_id: 'transfer-call', + content: 'Successfully transferred\n\nWork_items: Do not reinterpret this edge', + }), + ], + 'specialist', + ); + + expect(result?.instructions).toBeNull(); + expect(sdkProcess).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/agents/handoffPromptKeyCompatibility.ts b/packages/api/src/agents/handoffPromptKeyCompatibility.ts new file mode 100644 index 0000000000..f954fa8746 --- /dev/null +++ b/packages/api/src/agents/handoffPromptKeyCompatibility.ts @@ -0,0 +1,185 @@ +import { Constants } from '@librechat/agents'; +import { ToolMessage } from '@librechat/agents/langchain/messages'; +import type { GraphEdge, IState, Run, RunConfig } from '@librechat/agents'; +import type { BaseMessage } from '@librechat/agents/langchain/messages'; + +type HandoffReceptionResult = { + filteredMessages: BaseMessage[]; + instructions: string | null; + sourceAgentName: string | null; + parallelSiblings: string[]; +} | null; + +type ProcessHandoffReception = (messages: BaseMessage[], agentId: string) => HandoffReceptionResult; + +const PROCESS_HANDOFF_RECEPTION = 'processHandoffReception'; +const patchedGraphs = new WeakSet(); +const sdkSupportedPromptKeys = new Set(['instruction', 'instructions', 'context']); + +function capitalizeFirst(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function hasDestination(edge: GraphEdge, agentId: string): boolean { + const destinations = Array.isArray(edge.to) ? edge.to : [edge.to]; + return destinations.includes(agentId); +} + +function getCustomPromptLabels(edges: GraphEdge[], agentId: string): string[] { + const labels = new Set(); + + for (const edge of edges) { + const promptKey = edge.promptKey; + if ( + edge.edgeType === 'direct' || + typeof edge.prompt !== 'string' || + !promptKey || + sdkSupportedPromptKeys.has(promptKey.toLowerCase()) || + !hasDestination(edge, agentId) + ) { + continue; + } + labels.add(capitalizeFirst(promptKey)); + } + + return [...labels]; +} + +function findTransferMessageIndex(messages: BaseMessage[], agentId: string): number { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (!ToolMessage.isInstance(message)) { + continue; + } + + const isStandardTransfer = message.name === `${Constants.LC_TRANSFER_TO_}${agentId}`; + const isConditionalTransfer = + message.name === 'conditional_transfer' && + message.additional_kwargs.handoff_destination === agentId; + + if (isStandardTransfer || isConditionalTransfer) { + return index; + } + } + + return -1; +} + +function normalizeCustomPromptLabel(content: string, labels: string[]): string | null { + let matchedNeedle: string | null = null; + let matchedIndex = Number.POSITIVE_INFINITY; + + for (const label of labels) { + const needle = `\n\n${label}:`; + const index = content.indexOf(needle); + if (index >= 0 && index < matchedIndex) { + matchedNeedle = needle; + matchedIndex = index; + } + } + + if (matchedNeedle === null) { + return null; + } + + return ( + content.slice(0, matchedIndex) + + '\n\nInstructions:' + + content.slice(matchedIndex + matchedNeedle.length) + ); +} + +function cloneToolMessageWithContent(message: ToolMessage, content: string): ToolMessage { + return new ToolMessage({ + content, + id: message.id, + name: message.name, + tool_call_id: message.tool_call_id, + status: message.status, + artifact: message.artifact, + metadata: message.metadata, + additional_kwargs: message.additional_kwargs, + response_metadata: message.response_metadata, + }); +} + +/** + * Compatibility adapter for @librechat/agents 3.2.68, whose handoff receiver + * recognizes only Instructions/Context even though handoff tools can emit an + * arbitrary configured promptKey. The SDK remains authoritative: its method runs first, + * and the adapter retries with a cloned, normalized ToolMessage only when the + * SDK found the transfer but did not extract instructions. + * + * The adapter patches only the current Run graph and is intentionally + * self-disabling when the upstream receiver begins handling custom keys. + */ +export function applyCustomHandoffPromptKeyCompatibility( + run: Run, + graphConfig: RunConfig['graphConfig'], +): void { + if (graphConfig.type !== 'multi-agent') { + return; + } + + const edges = graphConfig.edges ?? []; + const hasCustomPromptKey = edges.some((edge) => { + const promptKey = edge.promptKey; + return ( + edge.edgeType !== 'direct' && + typeof edge.prompt === 'string' && + !!promptKey && + !sdkSupportedPromptKeys.has(promptKey.toLowerCase()) + ); + }); + if (!hasCustomPromptKey || !run.Graph || patchedGraphs.has(run.Graph)) { + return; + } + + const graph = run.Graph; + const graphMethods = graph as unknown as Record; + const candidate = graphMethods[PROCESS_HANDOFF_RECEPTION]; + if (typeof candidate !== 'function') { + return; + } + const original = candidate as ProcessHandoffReception; + + graphMethods[PROCESS_HANDOFF_RECEPTION] = function ( + this: unknown, + messages: BaseMessage[], + agentId: string, + ): HandoffReceptionResult { + const result = original.call(this, messages, agentId); + if (result === null || result.instructions !== null) { + return result; + } + + const labels = getCustomPromptLabels(edges, agentId); + if (labels.length === 0) { + return result; + } + + const transferIndex = findTransferMessageIndex(messages, agentId); + if (transferIndex < 0) { + return result; + } + + const transferMessage = messages[transferIndex]; + if (!ToolMessage.isInstance(transferMessage) || typeof transferMessage.content !== 'string') { + return result; + } + + const normalizedContent = normalizeCustomPromptLabel(transferMessage.content, labels); + if (normalizedContent === null) { + return result; + } + + const normalizedMessages = [...messages]; + normalizedMessages[transferIndex] = cloneToolMessageWithContent( + transferMessage, + normalizedContent, + ); + return original.call(this, normalizedMessages, agentId); + }; + + patchedGraphs.add(graph); +} diff --git a/packages/api/src/agents/hitl/resume.spec.ts b/packages/api/src/agents/hitl/resume.spec.ts index b199cf2eab..93b9d50b1e 100644 --- a/packages/api/src/agents/hitl/resume.spec.ts +++ b/packages/api/src/agents/hitl/resume.spec.ts @@ -1,3 +1,10 @@ +import { + ContentTypes, + GraphEvents, + StepTypes, + createContentAggregator, + type RunStep, +} from '@librechat/agents'; import type { Agents } from 'librechat-data-provider'; import { mapToolApprovalResolutions, @@ -6,6 +13,7 @@ import { findDisallowedDecisions, findIncompleteDecisions, createContentIndexOffsetHandlers, + hydrateResumeRunSteps, attachAskUserQuestionAnswer, attachAskUserQuestionArgs, } from './resume'; @@ -264,6 +272,136 @@ describe('createContentIndexOffsetHandlers', () => { }); }); +describe('hydrateResumeRunSteps', () => { + it('restores step and tool-call identity so a resumed completion updates its seeded card', () => { + const { contentParts, aggregateContent, stepMap } = createContentAggregator(); + contentParts.push( + { type: ContentTypes.TEXT, text: 'Before approval' }, + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'call-approval', + name: 'approval_probe', + args: '{"value":"before"}', + }, + }, + ); + const runStep: RunStep = { + id: 'step-approval', + runId: 'response-1', + type: StepTypes.TOOL_CALLS, + index: 1, + stepDetails: { + type: StepTypes.TOOL_CALLS, + tool_calls: [ + { + id: 'call-approval', + name: 'approval_probe', + args: { value: 'before' }, + }, + ], + }, + usage: null, + }; + const toolCallStepIds = new Map(); + + hydrateResumeRunSteps([runStep], stepMap, { toolCallStepIds }, contentParts); + aggregateContent({ + event: GraphEvents.ON_RUN_STEP_COMPLETED, + data: { + result: { + id: 'step-approval', + index: 1, + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'call-approval', + name: 'approval_probe', + args: { value: 'before' }, + output: 'approved output', + }, + }, + }, + }); + + expect(stepMap.get('step-approval')).toBe(runStep); + expect(toolCallStepIds.get('call-approval')).toBe('step-approval'); + expect(contentParts[1]).toMatchObject({ + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'call-approval', + output: 'approved output', + progress: 1, + }, + }); + }); + + it('realigns a stale persisted index to the seeded tool card by tool-call id', () => { + const { contentParts, aggregateContent, stepMap } = createContentAggregator(); + contentParts.push( + { type: ContentTypes.TEXT, text: 'Prepended after the step was recorded' }, + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'call-shifted', + name: 'approval_probe', + args: '{"value":"shifted"}', + }, + }, + ); + const staleRunStep: RunStep = { + id: 'step-shifted', + runId: 'response-1', + type: StepTypes.TOOL_CALLS, + index: 0, + stepDetails: { + type: StepTypes.TOOL_CALLS, + tool_calls: [ + { + id: 'call-shifted', + name: 'approval_probe', + args: { value: 'shifted' }, + }, + ], + }, + usage: null, + }; + const toolCallStepIds = new Map(); + + hydrateResumeRunSteps([staleRunStep], stepMap, { toolCallStepIds }, contentParts); + aggregateContent({ + event: GraphEvents.ON_RUN_STEP_COMPLETED, + data: { + result: { + id: 'step-shifted', + index: 0, + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'call-shifted', + name: 'approval_probe', + args: { value: 'shifted' }, + output: 'shifted output', + }, + }, + }, + }); + + expect(staleRunStep.index).toBe(0); + expect(stepMap.get('step-shifted')?.index).toBe(1); + expect(contentParts[0]).toEqual({ + type: ContentTypes.TEXT, + text: 'Prepended after the step was recorded', + }); + expect(contentParts[1]).toMatchObject({ + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'call-shifted', + output: 'shifted output', + progress: 1, + }, + }); + }); +}); + describe('attachAskUserQuestionAnswer', () => { const question = { question: 'Which env?', options: [{ label: 'Staging', value: 'staging' }] }; const askPart = (output?: string) => ({ diff --git a/packages/api/src/agents/hitl/resume.ts b/packages/api/src/agents/hitl/resume.ts index 58b465221c..4134ccb510 100644 --- a/packages/api/src/agents/hitl/resume.ts +++ b/packages/api/src/agents/hitl/resume.ts @@ -4,6 +4,7 @@ import type { ToolApprovalDecisionMap, AskUserQuestionResolution, EventHandler, + RunStep, } from '@librechat/agents'; import type { Agents } from 'librechat-data-provider'; import { ASK_USER_QUESTION_TOOL_NAME } from './askUserQuestionTool'; @@ -121,6 +122,82 @@ export function findIncompleteDecisions( .map((r) => r.tool_call_id); } +/** + * Reconcile persisted tool-step indices with the content being seeded into a + * rebuilt aggregator. + * + * A pause-time index is not durable identity: hosts can prepend content after + * the step was emitted, and persisted reconstruction can compact sparse + * content. Tool-call ids are stable across both operations, so use them to + * relocate a step without mutating the stored object. + */ +type ResumableRunStep = { + id: string; + index: number; + stepDetails: { + type: string; + tool_calls?: readonly { id?: string }[]; + }; +}; + +export function normalizeResumeRunStepIndices( + runSteps: readonly T[], + seedContent: readonly { type?: string; tool_call?: { id?: string } }[] = [], +): T[] { + const toolCallIndices = new Map(); + seedContent.forEach((part, index) => { + const toolCallId = part?.tool_call?.id; + if (part?.type === 'tool_call' && typeof toolCallId === 'string') { + toolCallIndices.set(toolCallId, index); + } + }); + + return runSteps.map((runStep) => { + if (runStep.stepDetails.type !== 'tool_calls') { + return runStep; + } + const contentIndex = runStep.stepDetails.tool_calls + ?.map((toolCall) => (toolCall.id ? toolCallIndices.get(toolCall.id) : undefined)) + .find((index) => index != null); + return contentIndex != null && contentIndex !== runStep.index + ? { ...runStep, index: contentIndex } + : runStep; + }); +} + +/** + * Restore the streamed run-step sidecars that a fresh SDK Run cannot recover + * from the LangGraph checkpoint by itself. + * + * Human-review resume can happen in a later request or process. The checkpoint + * restarts directly inside ToolNode, so it does not replay ON_RUN_STEP before + * dispatching ON_RUN_STEP_COMPLETED. Seeding both maps lets the ToolNode emit + * the original step id and lets the content aggregator resolve that id back to + * the already-rendered tool card. + */ +export function hydrateResumeRunSteps( + runSteps: readonly RunStep[], + stepMap: Map | undefined, + graph: { toolCallStepIds?: Map } | null | undefined, + seedContent: readonly { type?: string; tool_call?: { id?: string } }[] = [], +): void { + for (const runStep of normalizeResumeRunStepIndices(runSteps, seedContent)) { + if (!runStep?.id) { + continue; + } + stepMap?.set(runStep.id, runStep); + const stepDetails: ResumableRunStep['stepDetails'] = runStep.stepDetails; + if (stepDetails.type !== 'tool_calls') { + continue; + } + for (const toolCall of stepDetails.tool_calls ?? []) { + if (toolCall.id) { + graph?.toolCallStepIds?.set(toolCall.id, runStep.id); + } + } + } +} + /** * Wrap a resume run's event handlers so every content index the rebuilt graph * emits is shifted past the pre-pause content. diff --git a/packages/api/src/agents/memory.spec.ts b/packages/api/src/agents/memory.spec.ts index 463a115d00..22f24380ac 100644 --- a/packages/api/src/agents/memory.spec.ts +++ b/packages/api/src/agents/memory.spec.ts @@ -1,10 +1,11 @@ import { Types } from 'mongoose'; -import { Run, Providers } from '@librechat/agents'; -import { MemoryScope } from 'librechat-data-provider'; +import { Tools, MemoryScope } from 'librechat-data-provider'; +import { Run, Providers, GraphEvents } from '@librechat/agents'; import type { IUser } from '@librechat/data-schemas'; import type { Response } from 'express'; import { processMemory, + createMemoryProcessor, createMemoryTool, getMemoryAgentId, getRequestMemories, @@ -12,6 +13,7 @@ import { invalidateRequestMemories, agentHasInlineMemoryTools, } from './memory'; +import { GenerationJobManager } from '~/stream/GenerationJobManager'; jest.mock('~/stream/GenerationJobManager'); @@ -98,6 +100,72 @@ function createTestUser(overrides: Partial = {}): IUser { } as IUser; } +describe('Memory attachment generation fencing', () => { + it('emits artifacts with the generation epoch that started memory processing', async () => { + const memoryArtifact = { + type: 'update' as const, + key: 'response_style', + value: 'concise', + }; + const processStream = jest.fn(async () => { + const runConfig = (Run.create as jest.Mock).mock.calls[0][0]; + runConfig.customHandlers[GraphEvents.TOOL_END].handle( + GraphEvents.TOOL_END, + { + output: { + tool_call_id: 'memory-call-1', + artifact: { [Tools.memory]: memoryArtifact }, + }, + }, + { + run_id: 'response-1', + thread_id: 'conversation-1', + }, + ); + return 'success'; + }); + (Run.create as jest.Mock).mockReturnValueOnce({ processStream }); + + const [, runMemory] = await createMemoryProcessor({ + res: { + headersSent: true, + write: jest.fn(), + } as unknown as Response, + userId: 'user-1', + messageId: 'response-1', + conversationId: 'conversation-1', + streamId: 'conversation-1', + jobCreatedAt: 1234, + memoryMethods: { + setMemory: jest.fn(), + deleteMemory: jest.fn(), + getFormattedMemories: jest.fn().mockResolvedValue({ + withKeys: '', + withoutKeys: '', + totalTokens: 0, + }), + }, + }); + + await runMemory([]); + + expect(GenerationJobManager.emitChunk).toHaveBeenCalledWith( + 'conversation-1', + { + event: 'attachment', + data: { + type: Tools.memory, + toolCallId: 'memory-call-1', + messageId: 'response-1', + conversationId: 'conversation-1', + [Tools.memory]: memoryArtifact, + }, + }, + { expectedCreatedAt: 1234 }, + ); + }); +}); + describe('Memory Agent Header Resolution', () => { let testUser: IUser; let mockRes: Response; diff --git a/packages/api/src/agents/memory.ts b/packages/api/src/agents/memory.ts index 1f4c362be8..b7922ec7dd 100644 --- a/packages/api/src/agents/memory.ts +++ b/packages/api/src/agents/memory.ts @@ -701,6 +701,7 @@ export async function processMemory({ tokenLimit, totalTokens = 0, streamId = null, + jobCreatedAt, user, }: { res: ServerResponse; @@ -719,6 +720,7 @@ export async function processMemory({ totalTokens?: number; llmConfig?: Partial; streamId?: string | null; + jobCreatedAt?: number; user?: IUser; }): Promise<(TAttachment | null)[] | undefined> { try { @@ -826,7 +828,12 @@ ${memory ?? 'No existing memories'}`; }); const artifactPromises: Promise[] = []; - const memoryCallback = createMemoryCallback({ res, artifactPromises, streamId }); + const memoryCallback = createMemoryCallback({ + res, + artifactPromises, + streamId, + jobCreatedAt, + }); const customHandlers = { [GraphEvents.TOOL_END]: new BasicToolEndHandler(memoryCallback), }; @@ -926,6 +933,7 @@ export async function createMemoryProcessor({ conversationId, config = {}, streamId = null, + jobCreatedAt, user, }: { res: ServerResponse; @@ -937,6 +945,7 @@ export async function createMemoryProcessor({ memoryMethods: RequiredMemoryMethods; config?: MemoryConfig; streamId?: string | null; + jobCreatedAt?: number; user?: IUser; }): Promise<[string, (messages: BaseMessage[]) => Promise<(TAttachment | null)[] | undefined>]> { const { validKeys, instructions, llmConfig, tokenLimit } = config; @@ -961,6 +970,7 @@ export async function createMemoryProcessor({ messageId, tokenLimit, streamId, + jobCreatedAt, conversationId, memory: withKeys, totalTokens: totalTokens || 0, @@ -981,11 +991,13 @@ async function handleMemoryArtifact({ data, metadata, streamId = null, + jobCreatedAt, }: { res: ServerResponse; data: ToolEndData; metadata?: ToolEndMetadata; streamId?: string | null; + jobCreatedAt?: number; }) { const output = data?.output as ToolMessage | undefined; if (!output) { @@ -1012,7 +1024,11 @@ async function handleMemoryArtifact({ return attachment; } if (streamId) { - GenerationJobManager.emitChunk(streamId, { event: 'attachment', data: attachment }); + GenerationJobManager.emitChunk( + streamId, + { event: 'attachment', data: attachment }, + { expectedCreatedAt: jobCreatedAt }, + ); } else { res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`); } @@ -1025,16 +1041,19 @@ async function handleMemoryArtifact({ * @param params.res - The server response object * @param params.artifactPromises - Array to collect artifact promises * @param params.streamId - The stream ID for resumable mode, or null for standard mode + * @param params.jobCreatedAt - The generation epoch that owns emitted artifacts * @returns The memory callback function */ export function createMemoryCallback({ res, artifactPromises, streamId = null, + jobCreatedAt, }: { res: ServerResponse; artifactPromises: Promise | null>[]; streamId?: string | null; + jobCreatedAt?: number; }): ToolEndCallback { return async (data: ToolEndData, metadata?: Record) => { const output = data?.output as ToolMessage | undefined; @@ -1043,7 +1062,7 @@ export function createMemoryCallback({ return; } artifactPromises.push( - handleMemoryArtifact({ res, data, metadata, streamId }).catch((error) => { + handleMemoryArtifact({ res, data, metadata, streamId, jobCreatedAt }).catch((error) => { logger.error('Error processing memory artifact content:', error); return null; }), diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 2dc42ca707..1ffe249b2d 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -47,6 +47,7 @@ import { createAskUserQuestionTool, } from '~/agents/hitl/askUserQuestionTool'; import { resolveToolApprovalPolicy, exemptAskUserQuestionFromApproval } from '~/agents/hitl/policy'; +import { applyCustomHandoffPromptKeyCompatibility } from '~/agents/handoffPromptKeyCompatibility'; import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm'; import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from '~/agents/tools'; import { getProviderConfig } from '~/endpoints/config/providers'; @@ -1361,7 +1362,7 @@ export async function createRun({ const graphConfig: RunConfig['graphConfig'] = { signal, agents: agentInputs, - edges: agents[0].edges, + edges: agents[0].edges ?? [], }; if (agentInputs.length > 1 || ((graphConfig as MultiAgentGraphConfig).edges?.length ?? 0) > 0) { @@ -1558,6 +1559,7 @@ export async function createRun({ }; const run = await Run.create(runConfig); + applyCustomHandoffPromptKeyCompatibility(run, runConfig.graphConfig); applyTestRunHook(run, { messages, agents }); return run; } diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index a637452f24..52849a617e 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -40,6 +40,7 @@ import { import { isPendingActionStale, isPendingActionExpired } from './interfaces/IJobStore'; import { InMemoryEventTransport } from './implementations/InMemoryEventTransport'; import { InMemoryJobStore } from './implementations/InMemoryJobStore'; +import { normalizeResumeRunStepIndices } from '~/agents/hitl/resume'; import { emitChunkWithReceipt } from './internal/chunkPublication'; import { filterPersistableAbortContent } from './abortContent'; import { toClientPendingAction } from '~/agents/hitl/policy'; @@ -132,6 +133,34 @@ function isOAuthReplayEvent(event: t.ServerSentEvent): boolean { return false; } +function normalizeRunStepReplayIndices( + replayEvents: t.ResumeState['replayEvents'], + runSteps: readonly Agents.RunStep[], +): t.ResumeState['replayEvents'] { + if (!replayEvents) { + return replayEvents; + } + const runStepsById = new Map(runSteps.map((runStep) => [runStep.id, runStep])); + return replayEvents.map((event) => { + if (event.event !== 'on_run_step' || event.data == null || typeof event.data !== 'object') { + return event; + } + const stepId = 'id' in event.data ? event.data.id : undefined; + const normalizedRunStep = typeof stepId === 'string' ? runStepsById.get(stepId) : undefined; + const eventIndex = 'index' in event.data ? event.data.index : undefined; + if (!normalizedRunStep || normalizedRunStep.index === eventIndex) { + return event; + } + return { + ...event, + data: { + ...event.data, + index: normalizedRunStep.index, + }, + }; + }); +} + /** * Configuration options for GenerationJobManager */ @@ -2377,10 +2406,14 @@ class GenerationJobManagerClass { async emitChunk( streamId: string, event: t.ServerSentEvent, - options?: { durable?: boolean }, + options?: { durable?: boolean; expectedCreatedAt?: number }, ): Promise { const runtime = this.runtimeState.get(streamId); - if (!runtime || !this.isCurrentRuntime(streamId, runtime)) { + if ( + !runtime || + (options?.expectedCreatedAt != null && runtime.createdAt !== options.expectedCreatedAt) || + !this.isCurrentRuntime(streamId, runtime) + ) { return; } @@ -2468,14 +2501,24 @@ class GenerationJobManagerClass { } markSnapshotReady(); + // Retain run-step identity independently of the live graph. Paused in-memory + // runs release that graph before a later request rebuilds the run, but the + // resume path still needs the original step ids to correlate tool results. + const eventObj = event as Record; + const eventType = eventObj.event as string | undefined; + const eventData = eventObj.data; + if ( + (eventType === 'on_run_step' || eventType === 'on_run_step_completed') && + eventData != null && + typeof eventData === 'object' + ) { + this.saveRunStepFromEvent(streamId, eventData as Record, runtime.createdAt); + } + // For Redis mode, persist chunk for later reconstruction (fire-and-forget for resumability) if (this._isRedis) { // The SSE event structure is { event: string, data: unknown, ... } // The aggregator expects { event: string, data: unknown } where data is the payload - const eventObj = event as Record; - const eventType = eventObj.event as string | undefined; - const eventData = eventObj.data; - if (eventType && eventData !== undefined) { // Store in format expected by aggregateContent: { event, data } const appendPromise = this.jobStore @@ -2484,15 +2527,6 @@ class GenerationJobManagerClass { logger.error(`[GenerationJobManager] Failed to append chunk:`, err); }); - // For run step events, also save to run steps key for quick retrieval - if (eventType === 'on_run_step' || eventType === 'on_run_step_completed') { - this.saveRunStepFromEvent( - streamId, - eventData as Record, - runtime.createdAt, - ); - } - if (options?.durable === true) { await appendPromise; if (!this.isCurrentRuntime(streamId, runtime)) { @@ -2621,9 +2655,9 @@ class GenerationJobManagerClass { } /** - * Accumulate run steps for a stream (Redis mode only). - * Uses a simple in-memory buffer that gets flushed to Redis. - * Not used in in-memory mode - run steps come from live graph via WeakRef. + * Accumulate run steps for a stream. + * Redis stores flush this buffer for cross-replica recovery; in-memory stores + * retain it as a fallback after a paused run's live graph has been released. */ private runStepBuffers: Map | null = null; @@ -2632,7 +2666,7 @@ class GenerationJobManagerClass { runStep: Agents.RunStep, expectedCreatedAt: number, ): void { - // Lazy initialization - only create map when first used (Redis mode) + // Lazy initialization keeps the per-stream allocation off non-agent paths. if (!this.runStepBuffers) { this.runStepBuffers = new Map(); } @@ -3024,6 +3058,16 @@ class GenerationJobManagerClass { this.jobStore.peekSteers(streamId, jobData.createdAt), ]); const aggregatedContent = result?.content ?? []; + const bufferState = this.runStepBuffers?.get(streamId); + const bufferedRunSteps = bufferState?.createdAt === jobData.createdAt ? bufferState.steps : []; + const runStepsById = new Map(runSteps.map((runStep) => [runStep.id, runStep])); + for (const runStep of bufferedRunSteps) { + runStepsById.set(runStep.id, runStep); + } + const effectiveRunSteps = normalizeResumeRunStepIndices( + [...runStepsById.values()], + aggregatedContent, + ); let titleEvent: t.ResumeState['titleEvent']; if (jobData.titleEvent) { try { @@ -3036,6 +3080,7 @@ class GenerationJobManagerClass { if (jobData.replayEvents) { try { replayEvents = JSON.parse(jobData.replayEvents) as t.ResumeState['replayEvents']; + replayEvents = normalizeRunStepReplayIndices(replayEvents, effectiveRunSteps); } catch { // Ignore malformed persisted replay events. } @@ -3067,13 +3112,13 @@ class GenerationJobManagerClass { logger.debug(`[GenerationJobManager] getResumeState:`, { streamId, - runStepsLength: runSteps.length, + runStepsLength: effectiveRunSteps.length, aggregatedContentLength: aggregatedContent.length, collectedUsageLength: collectedUsage?.length ?? 0, }); return { - runSteps, + runSteps: effectiveRunSteps, aggregatedContent, userMessage: jobData.userMessage, responseMessageId: jobData.responseMessageId, @@ -3393,7 +3438,7 @@ class GenerationJobManagerClass { this.eventTransport.cleanup(streamId); } - // Also check runStepBuffers for any orphaned entries (Redis mode only) + // Also check runStepBuffers for any orphaned entries. if (this.runStepBuffers) { for (const streamId of this.runStepBuffers.keys()) { if (!(await this.jobStore.hasJob(streamId))) { diff --git a/packages/api/src/stream/__tests__/GenerationJobManager.resumeReplay.spec.ts b/packages/api/src/stream/__tests__/GenerationJobManager.resumeReplay.spec.ts index c9721efc00..82a726caae 100644 --- a/packages/api/src/stream/__tests__/GenerationJobManager.resumeReplay.spec.ts +++ b/packages/api/src/stream/__tests__/GenerationJobManager.resumeReplay.spec.ts @@ -1,3 +1,5 @@ +import type { StandardGraph } from '@librechat/agents'; +import type { Agents } from 'librechat-data-provider'; import type { ServerSentEvent } from '~/types'; import { InMemoryEventTransport } from '~/stream/implementations/InMemoryEventTransport'; import { InMemoryJobStore } from '~/stream/implementations/InMemoryJobStore'; @@ -6,9 +8,13 @@ import { GenerationJobManagerClass } from '~/stream/GenerationJobManager'; jest.spyOn(console, 'log').mockImplementation(); function createInMemoryManager(): GenerationJobManagerClass { + return createManagerWithStore(new InMemoryJobStore({ ttlAfterComplete: 60000 })); +} + +function createManagerWithStore(store: InMemoryJobStore): GenerationJobManagerClass { const manager = new GenerationJobManagerClass(); manager.configure({ - jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }), + jobStore: store, eventTransport: new InMemoryEventTransport(), isRedis: false, }); @@ -30,6 +36,18 @@ class SnapshotReplayJobStore extends InMemoryJobStore { } } +class PartialRunStepJobStore extends InMemoryJobStore { + private persistedRunSteps: Agents.RunStep[] = []; + + setPersistedRunSteps(runSteps: Agents.RunStep[]): void { + this.persistedRunSteps = runSteps; + } + + async getRunSteps(): Promise { + return this.persistedRunSteps; + } +} + function createSnapshotReplayManager(): GenerationJobManagerClass { const manager = new GenerationJobManagerClass(); manager.configure({ @@ -109,6 +127,226 @@ describe('GenerationJobManager resume replay events', () => { expect(resumeState?.replayEvents).toEqual([runStepEvent, authEvent]); }); + test('retains emitted run steps when the live graph is unavailable during resume', async () => { + manager = createInMemoryManager(); + const streamId = `run-step-resume-${Date.now()}`; + await manager.createJob(streamId, 'user-1', streamId); + + const runStep = { + id: 'step-approval', + runId: 'response-1', + index: 1, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-approval', name: 'approval_probe', args: '{}' }], + }, + }; + + await manager.emitChunk(streamId, { + event: 'on_run_step', + data: runStep, + }); + + const resumeState = await manager.getResumeState(streamId); + + expect(resumeState?.runSteps).toEqual([runStep]); + }); + + test('realigns a stale run-step index to the aggregated tool card by tool-call id', async () => { + manager = createInMemoryManager(); + const streamId = `run-step-index-resume-${Date.now()}`; + await manager.createJob(streamId, 'user-1', streamId); + manager.setContentParts(streamId, [ + { type: 'text', text: 'Prepended content' }, + { + type: 'tool_call', + tool_call: { + id: 'call-shifted', + name: 'approval_probe', + args: '{"value":"shifted"}', + }, + }, + ]); + + const staleRunStep = { + id: 'step-shifted', + runId: 'response-1', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-shifted', name: 'approval_probe', args: '{}' }], + }, + }; + await manager.emitChunk(streamId, { + event: 'on_run_step', + data: staleRunStep, + }); + + const resumeState = await manager.getResumeState(streamId); + + expect(staleRunStep.index).toBe(0); + expect(resumeState?.runSteps).toEqual([{ ...staleRunStep, index: 1 }]); + }); + + test('realigns the persisted OAuth start replay with its normalized run-step index', async () => { + manager = createInMemoryManager(); + const streamId = `oauth-index-resume-${Date.now()}`; + await manager.createJob(streamId, 'user-1', streamId); + manager.setContentParts(streamId, [ + { type: 'text', text: 'Prepended content' }, + { + type: 'tool_call', + tool_call: { + id: 'call-oauth-shifted', + name: 'oauth_mcp_Google-Workspace', + args: '', + }, + }, + ]); + const staleReplayEvent = { + event: 'on_run_step', + data: { + id: 'step-oauth-shifted', + runId: 'USE_PRELIM_RESPONSE_MESSAGE_ID', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [ + { + id: 'call-oauth-shifted', + name: 'oauth_mcp_Google-Workspace', + args: '', + }, + ], + }, + }, + } satisfies ServerSentEvent; + + await manager.emitChunk(streamId, staleReplayEvent); + const resumeState = await manager.getResumeState(streamId); + + expect(staleReplayEvent.data.index).toBe(0); + expect(resumeState?.runSteps[0]?.index).toBe(1); + expect(resumeState?.replayEvents).toEqual([ + { + ...staleReplayEvent, + data: { ...staleReplayEvent.data, index: 1 }, + }, + ]); + }); + + test('merges persisted and buffered run steps, preferring the buffered version by id', async () => { + const store = new PartialRunStepJobStore({ ttlAfterComplete: 60000 }); + manager = createManagerWithStore(store); + const streamId = `run-step-merge-resume-${Date.now()}`; + await manager.createJob(streamId, 'user-1', streamId); + + const persistedStep = { + id: 'step-persisted', + runId: 'response-1', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-persisted', name: 'approval_probe', args: '{}' }], + }, + } as Agents.RunStep; + const updatedPersistedStep = { ...persistedStep, index: 3 }; + const bufferedOnlyStep = { + ...persistedStep, + id: 'step-buffered', + index: 4, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-buffered', name: 'approval_probe', args: '{}' }], + }, + } as Agents.RunStep; + store.setPersistedRunSteps([persistedStep]); + + await manager.emitChunk(streamId, { + event: 'on_run_step', + data: updatedPersistedStep, + }); + await manager.emitChunk(streamId, { + event: 'on_run_step', + data: bufferedOnlyStep, + }); + + const resumeState = await manager.getResumeState(streamId); + + expect(resumeState?.runSteps).toEqual([updatedPersistedStep, bufferedOnlyStep]); + }); + + test('does not carry live content or run steps into a replacement job with the same stream id', async () => { + const store = new InMemoryJobStore({ ttlAfterComplete: 60000 }); + manager = createManagerWithStore(store); + const streamId = `run-step-replacement-${Date.now()}`; + await manager.createJob(streamId, 'user-1', streamId); + const oldRunStep = { + id: 'step-old-job', + runId: 'response-old', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-old-job', name: 'approval_probe', args: '{}' }], + }, + } as Agents.RunStep; + await manager.emitChunk(streamId, { + event: 'on_run_step', + data: oldRunStep, + }); + store.setGraph(streamId, { + contentData: [oldRunStep], + } as unknown as StandardGraph); + store.setContentParts(streamId, [{ type: 'text', text: 'old content' }]); + store.setCollectedUsage(streamId, [{ input_tokens: 1, output_tokens: 2 }]); + + await manager.createJob(streamId, 'user-1', streamId); + + const resumeState = await manager.getResumeState(streamId); + expect(resumeState?.runSteps).toEqual([]); + expect(resumeState?.aggregatedContent).toEqual([]); + expect(store.getCollectedUsage(streamId)).toEqual([]); + }); + + test('rejects a delayed predecessor run step after the stream id is replaced', async () => { + manager = createInMemoryManager(); + const streamId = `run-step-delayed-predecessor-${Date.now()}`; + const predecessor = await manager.createJob(streamId, 'user-1', streamId); + const replacement = await manager.createJob(streamId, 'user-1', streamId); + const predecessorRunStep = { + id: 'step-predecessor', + runId: 'response-predecessor', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-predecessor', name: 'approval_probe', args: '{}' }], + }, + }; + const replacementRunStep = { + id: 'step-replacement', + runId: 'response-replacement', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-replacement', name: 'approval_probe', args: '{}' }], + }, + }; + + await manager.emitChunk( + streamId, + { event: 'on_run_step', data: predecessorRunStep }, + { expectedCreatedAt: predecessor.createdAt }, + ); + await manager.emitChunk( + streamId, + { event: 'on_run_step', data: replacementRunStep }, + { expectedCreatedAt: replacement.createdAt }, + ); + + const resumeState = await manager.getResumeState(streamId); + expect(resumeState?.runSteps).toEqual([replacementRunStep]); + }); + test('replaces OAuth replay event for the same step id', async () => { manager = createInMemoryManager(); const streamId = `oauth-delta-replace-${Date.now()}`; diff --git a/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts b/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts index 18097d6914..ca8a40f82a 100644 --- a/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts +++ b/packages/api/src/stream/__tests__/RedisJobStore.stream_integration.spec.ts @@ -682,6 +682,142 @@ describe('RedisJobStore Integration Tests', () => { await store.destroy(); }); + test('createJob clears persisted and live content state when a stream id is reused', async () => { + if (!ioredisClient) { + return; + } + + const { RedisJobStore } = await import('../implementations/RedisJobStore'); + const store = new RedisJobStore(ioredisClient); + await store.initialize(); + + const streamId = `stale-run-steps-${Date.now()}`; + await store.createJob(streamId, 'user-1', streamId); + const oldRunStep = { + id: 'step-old-job', + runId: 'response-old', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-old-job', name: 'approval_probe', args: '{}' }], + }, + } as Agents.RunStep; + await store.saveRunSteps(streamId, [oldRunStep]); + await store.appendChunk(streamId, { + event: 'on_run_step', + data: { + id: 'old-message-step', + runId: 'old-run', + index: 0, + stepDetails: { type: 'message_creation' }, + }, + }); + await store.appendChunk(streamId, { + event: 'on_message_delta', + data: { + id: 'old-message-step', + delta: { content: { type: 'text', text: 'old durable content' } }, + }, + }); + await store.updateJob(streamId, { + completedAt: Date.now(), + error: 'old error', + userMessage: { + messageId: 'old-user-message', + text: 'old user message', + }, + responseMessageId: 'old-response-message', + discoveredTools: ['old_tool'], + createdEventEmitted: true, + sender: 'Old sender', + finalEvent: '{"event":"old-final"}', + titleEvent: '{"event":"old-title"}', + replayEvents: '[{"event":"old-replay"}]', + contextUsage: '{"usedTokens":10}', + tokenUsage: '[{"input_tokens":1,"output_tokens":2}]', + endpoint: 'old-endpoint', + iconURL: 'https://example.com/old.png', + model: 'old-model', + promptTokens: 10, + agent_id: 'old-agent', + isTemporary: true, + }); + store.setGraph(streamId, { + getContentParts: () => [{ type: 'text', text: 'old graph content' }], + getRunSteps: () => [oldRunStep], + } as unknown as StandardGraph); + store.setContentParts(streamId, [{ type: 'text', text: 'old host content' }]); + store.setCollectedUsage(streamId, [{ input_tokens: 1, output_tokens: 2 }]); + expect(await store.getRunSteps(streamId)).toHaveLength(1); + + await store.createJob(streamId, 'user-1', streamId); + + expect(await store.getRunSteps(streamId)).toEqual([]); + expect(await store.getContentParts(streamId)).toBeNull(); + expect(store.getCollectedUsage(streamId)).toEqual([]); + expect(await store.getJob(streamId)).toEqual( + expect.objectContaining({ + streamId, + userId: 'user-1', + conversationId: streamId, + status: 'running', + syncSent: false, + }), + ); + expect(await store.getJob(streamId)).not.toEqual( + expect.objectContaining({ + responseMessageId: 'old-response-message', + }), + ); + await store.destroy(); + }); + + test('createJob preserves the prior live content state when replacement persistence fails', async () => { + if (!ioredisClient) { + return; + } + + const { RedisJobStore } = await import('../implementations/RedisJobStore'); + const store = new RedisJobStore(ioredisClient); + await store.initialize(); + + const streamId = `failed-replacement-${Date.now()}`; + const oldRunStep = { + id: 'step-old-job', + runId: 'response-old', + index: 0, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call-old-job', name: 'approval_probe', args: '{}' }], + }, + } as Agents.RunStep; + await store.createJob(streamId, 'user-1', streamId); + store.setGraph(streamId, { + getContentParts: () => [{ type: 'text', text: 'old graph content' }], + getRunSteps: () => [oldRunStep], + } as unknown as StandardGraph); + store.setContentParts(streamId, [{ type: 'text', text: 'old host content' }]); + store.setCollectedUsage(streamId, [{ input_tokens: 1, output_tokens: 2 }]); + + const evalSpy = jest + .spyOn(ioredisClient, 'eval') + .mockRejectedValueOnce(new Error('replacement write failed')); + try { + await expect(store.createJob(streamId, 'user-1', streamId)).rejects.toThrow( + 'replacement write failed', + ); + } finally { + evalSpy.mockRestore(); + } + + expect(await store.getRunSteps(streamId)).toEqual([oldRunStep]); + expect(await store.getContentParts(streamId)).toEqual({ + content: [{ type: 'text', text: 'old host content' }], + }); + expect(store.getCollectedUsage(streamId)).toEqual([{ input_tokens: 1, output_tokens: 2 }]); + await store.destroy(); + }); + test('should not drop paused jobs from user tracking when cleanup sees a stale running index', async () => { if (!ioredisClient) { return; diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 254ac5558f..0c86c97db1 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -80,7 +80,7 @@ "librechat-data-provider": "*", "lodash": "^4.17.23", "meilisearch": "^0.38.0", - "mongoose": "^8.23.1", + "mongoose": "^8.24.1", "nanoid": "^3.3.7", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0" diff --git a/packages/data-schemas/src/methods/agent.spec.ts b/packages/data-schemas/src/methods/agent.spec.ts index ec55d0fbab..035d37e3ca 100644 --- a/packages/data-schemas/src/methods/agent.spec.ts +++ b/packages/data-schemas/src/methods/agent.spec.ts @@ -772,47 +772,172 @@ describe('Agent Methods', () => { expect(aclEntriesAfter).toHaveLength(0); }); - test('should remove handoff edges referencing deleted agent from other agents', async () => { + test('should remove a deleted agent from scalar and array edge endpoints', async () => { const authorId = new mongoose.Types.ObjectId(); - const targetAgentId = `agent_${uuidv4()}`; + const deletedAgentId = `agent_${uuidv4()}`; + const graphAgentId = `agent_${uuidv4()}`; const sourceAgentId = `agent_${uuidv4()}`; + const targetAgentId = `agent_${uuidv4()}`; - // Create target agent (handoff destination) await createAgent({ - id: targetAgentId, - name: 'Target Agent', + id: deletedAgentId, + name: 'Agent To Delete', provider: 'test', model: 'test-model', author: authorId, }); - // Create source agent with handoff edge to target await createAgent({ - id: sourceAgentId, - name: 'Source Agent', + id: graphAgentId, + name: 'Agent With Connected Edges', provider: 'test', model: 'test-model', author: authorId, edges: [ + { + from: deletedAgentId, + to: targetAgentId, + edgeType: 'handoff', + }, + { + from: sourceAgentId, + to: deletedAgentId, + edgeType: 'handoff', + }, + { + from: [deletedAgentId, sourceAgentId], + to: targetAgentId, + edgeType: 'direct', + }, + { + from: sourceAgentId, + to: [deletedAgentId, targetAgentId], + edgeType: 'handoff', + }, + { + from: [deletedAgentId], + to: targetAgentId, + edgeType: 'handoff', + }, + { + from: sourceAgentId, + to: [deletedAgentId], + edgeType: 'handoff', + }, + { + from: [deletedAgentId, sourceAgentId], + to: [deletedAgentId, targetAgentId], + edgeType: 'direct', + }, { from: sourceAgentId, to: targetAgentId, edgeType: 'handoff', + description: 'Unrelated edge', }, ], }); - // Verify edge exists before deletion - const sourceAgentBefore = await getAgent({ id: sourceAgentId }); - expect(sourceAgentBefore!.edges).toHaveLength(1); - expect(sourceAgentBefore!.edges![0].to).toBe(targetAgentId); + await deleteAgent({ id: deletedAgentId }); - // Delete the target agent - await deleteAgent({ id: targetAgentId }); + const graphAgent = await getAgent({ id: graphAgentId }); + expect(graphAgent!.edges).toEqual([ + { + from: [sourceAgentId], + to: targetAgentId, + edgeType: 'direct', + }, + { + from: sourceAgentId, + to: [targetAgentId], + edgeType: 'handoff', + }, + { + from: [sourceAgentId], + to: [targetAgentId], + edgeType: 'direct', + }, + { + from: sourceAgentId, + to: targetAgentId, + edgeType: 'handoff', + description: 'Unrelated edge', + }, + ]); + }); - // Verify the edge is removed from source agent - const sourceAgentAfter = await getAgent({ id: sourceAgentId }); - expect(sourceAgentAfter!.edges).toHaveLength(0); + test('should remove every bulk-deleted agent while preserving surviving edge members', async () => { + const deletingAuthorId = new mongoose.Types.ObjectId(); + const graphAuthorId = new mongoose.Types.ObjectId(); + const firstDeletedId = `agent_${uuidv4()}`; + const secondDeletedId = `agent_${uuidv4()}`; + const graphAgentId = `agent_${uuidv4()}`; + const sourceAgentId = `agent_${uuidv4()}`; + const targetAgentId = `agent_${uuidv4()}`; + + await createAgent({ + id: firstDeletedId, + name: 'First Bulk-Deleted Agent', + provider: 'test', + model: 'test-model', + author: deletingAuthorId, + }); + await createAgent({ + id: secondDeletedId, + name: 'Second Bulk-Deleted Agent', + provider: 'test', + model: 'test-model', + author: deletingAuthorId, + }); + await createAgent({ + id: graphAgentId, + name: 'Bulk Edge Graph', + provider: 'test', + model: 'test-model', + author: graphAuthorId, + edges: [ + { + from: [firstDeletedId, sourceAgentId], + to: [secondDeletedId, targetAgentId], + edgeType: 'direct', + }, + { + from: firstDeletedId, + to: targetAgentId, + edgeType: 'handoff', + }, + { + from: sourceAgentId, + to: [firstDeletedId, secondDeletedId], + edgeType: 'handoff', + }, + { + from: sourceAgentId, + to: targetAgentId, + edgeType: 'handoff', + description: 'Unrelated bulk edge', + }, + ], + }); + + await deleteUserAgents(deletingAuthorId.toString()); + + expect(await getAgent({ id: firstDeletedId })).toBeNull(); + expect(await getAgent({ id: secondDeletedId })).toBeNull(); + const graphAgent = await getAgent({ id: graphAgentId }); + expect(graphAgent!.edges).toEqual([ + { + from: [sourceAgentId], + to: [targetAgentId], + edgeType: 'direct', + }, + { + from: sourceAgentId, + to: targetAgentId, + edgeType: 'handoff', + description: 'Unrelated bulk edge', + }, + ]); }); test('should remove agent from user favorites when agent is deleted', async () => { diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index 509e13f811..d88b8443fd 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -6,8 +6,8 @@ import { actionDelimiter, isActionTool, } from 'librechat-data-provider'; +import type { FilterQuery, Model, PipelineStage, Types } from 'mongoose'; import type { AgentToolResources } from 'librechat-data-provider'; -import type { FilterQuery, Model, Types } from 'mongoose'; import type { IAgent, IAclEntry } from '~/types'; import { filterExistingSkillIds } from './skill'; import logger from '~/config/winston'; @@ -28,6 +28,84 @@ const TOOL_RESOURCE_KEYS: ReadonlyArray = [ EToolResources.ocr, ]; +/** Builds an atomic update that prunes deleted IDs without discarding surviving edge members. */ +function createEdgeCleanupPipeline(agentIds: string[]): PipelineStage[] { + const cleanEndpoint = (endpoint: string) => ({ + $cond: [ + { $isArray: endpoint }, + { + $filter: { + input: endpoint, + as: 'agentId', + cond: { $not: [{ $in: ['$$agentId', agentIds] }] }, + }, + }, + { $cond: [{ $in: [endpoint, agentIds] }, null, endpoint] }, + ], + }); + const hasEndpoint = (endpoint: string) => ({ + $cond: [{ $isArray: endpoint }, { $gt: [{ $size: endpoint }, 0] }, { $ne: [endpoint, null] }], + }); + + return [ + { + $set: { + edges: { + $filter: { + input: { + $map: { + input: { $ifNull: ['$edges', []] }, + as: 'edge', + in: { + $let: { + vars: { + cleanedFrom: cleanEndpoint('$$edge.from'), + cleanedTo: cleanEndpoint('$$edge.to'), + }, + in: { + $cond: [ + { + $and: [hasEndpoint('$$cleanedFrom'), hasEndpoint('$$cleanedTo')], + }, + { + $mergeObjects: [ + '$$edge', + { + from: '$$cleanedFrom', + to: '$$cleanedTo', + }, + ], + }, + null, + ], + }, + }, + }, + }, + }, + as: 'edge', + cond: { $ne: ['$$edge', null] }, + }, + }, + }, + }, + ]; +} + +/** Removes deleted agent references from every active graph that contains them. */ +async function removeAgentIdsFromEdges(Agent: Model, agentIds: string[]): Promise { + if (agentIds.length === 0) { + return; + } + + await Agent.updateMany( + { + $or: [{ 'edges.from': { $in: agentIds } }, { 'edges.to': { $in: agentIds } }], + }, + createEdgeCleanupPipeline(agentIds), + ); +} + export interface AgentDeps { /** Removes all ACL permissions for a resource. Injected from PermissionService. */ removeAllPermissions: (params: { resourceType: string; resourceId: unknown }) => Promise; @@ -745,10 +823,7 @@ export function createAgentMethods( }), ]); try { - await Agent.updateMany( - { 'edges.to': (agent as unknown as { id: string }).id }, - { $pull: { edges: { to: (agent as unknown as { id: string }).id } } }, - ); + await removeAgentIdsFromEdges(Agent, [(agent as unknown as { id: string }).id]); } catch (error) { logger.error('[deleteAgent] Error removing agent from handoff edges', error); } @@ -820,10 +895,7 @@ export function createAgentMethods( }); try { - await Agent.updateMany( - { 'edges.to': { $in: agentIds } }, - { $pull: { edges: { to: { $in: agentIds } } } }, - ); + await removeAgentIdsFromEdges(Agent, agentIds); } catch (error) { logger.error('[deleteUserAgents] Error removing agents from handoff edges', error); }