diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 2f001ca790d..19c65c775b2 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -948,6 +948,9 @@ describe('ResumableAgentController resume metadata', () => { expect(initializeClient).toHaveBeenCalledWith( expect.objectContaining({ checkpointNamespace: '1000', jobCreatedAt: 1000 }), ); + const [{ foregroundRunId, requestBody }] = initializeClient.mock.calls[0]; + expect(foregroundRunId).toBe(requestBody.messageId); + expect(foregroundRunId).not.toBe(req.body.messageId); expect(req.turnStartedAt).toBe(1000); expect(mockGenerationJobManager.updateMetadata).not.toHaveBeenCalled(); const startupMilestones = mockStartupTelemetry.mark.mock.calls.map(([milestone]) => milestone); diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index 014ea3fb846..dde2514f66b 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -428,20 +428,23 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { }); mockAddTitle = jest.fn().mockResolvedValue(undefined); - mockInitializeClient = jest.fn(async ({ req, checkpointNamespace, requestBody }) => { - // Capture the request state the controller seeds BEFORE reconstruction. - capturedInit = { - parentMessageId: req.body.parentMessageId, - files: req.body.files, - isTemporary: req.body.isTemporary, - turnStartedAt: req.turnStartedAt, - isScheduledFire: req._isScheduledFire, - timezone: req.body.timezone, - checkpointNamespace, - requestBody, - }; - return { client: makeClient(), userMCPAuthMap: { server1: { token: 't' } } }; - }); + mockInitializeClient = jest.fn( + async ({ req, checkpointNamespace, foregroundRunId, requestBody }) => { + // Capture the request state the controller seeds BEFORE reconstruction. + capturedInit = { + parentMessageId: req.body.parentMessageId, + files: req.body.files, + isTemporary: req.body.isTemporary, + turnStartedAt: req.turnStartedAt, + isScheduledFire: req._isScheduledFire, + timezone: req.body.timezone, + checkpointNamespace, + foregroundRunId, + requestBody, + }; + return { client: makeClient(), userMCPAuthMap: { server1: { token: 't' } } }; + }, + ); app = express(); app.use(express.json()); @@ -2799,6 +2802,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { conversationId: CONVO_ID, parentMessageId: USER_MSG_ID, }); + expect(capturedInit.foregroundRunId).toBe(RESPONSE_MSG_ID); expect(mockInitializeClient).toHaveBeenCalledTimes(1); const client = await mockInitializeClient.mock.results[0].value.then((r) => r.client); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 3198859a27c..a9fa0de4296 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -1907,6 +1907,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit signal: job.abortController.signal, jobCreatedAt, checkpointNamespace: job.metadata?.checkpointNamespace, + foregroundRunId: mcpRequestBody.messageId, requestBody: mcpRequestBody, }); startupTelemetry?.mark('client_initialized'); diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index f679ab98b66..3b55b714abb 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -1815,6 +1815,14 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) ); } + const mcpRequestBody = + job.metadata.mcpRequestBody ?? + createMCPRuntimeRequestBody({ + messageId: job.metadata.responseMessageId, + conversationId: streamId, + codeWorkspaces: req.body.codeWorkspaces ?? req.resolvedConversation?.codeWorkspaces, + parentMessageId: job.metadata.userMessage?.messageId ?? Constants.NO_PARENT, + }); const result = await initializeClient({ req, res, @@ -1822,14 +1830,8 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle) signal: job.abortController.signal, jobCreatedAt: job.createdAt, checkpointNamespace, - requestBody: - job.metadata.mcpRequestBody ?? - createMCPRuntimeRequestBody({ - messageId: job.metadata.responseMessageId, - conversationId: streamId, - codeWorkspaces: req.body.codeWorkspaces ?? req.resolvedConversation?.codeWorkspaces, - parentMessageId: job.metadata.userMessage?.messageId ?? Constants.NO_PARENT, - }), + foregroundRunId: mcpRequestBody.messageId, + requestBody: mcpRequestBody, }); client = result.client; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 0a412070e38..d3df7cc68a8 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -165,6 +165,7 @@ function createToolLoader( * @param {Object} params.endpointOption * @param {number} [params.jobCreatedAt] * @param {string} [params.checkpointNamespace] Immutable saver-level generation scope + * @param {string} [params.foregroundRunId] Canonical response identity for foreground execution * @param {import('@librechat/api').MCPRuntimeRequestBody} [params.requestBody] */ const initializeClient = async ({ @@ -174,6 +175,7 @@ const initializeClient = async ({ endpointOption, jobCreatedAt, checkpointNamespace, + foregroundRunId, requestBody, }) => { if (!endpointOption) { @@ -409,7 +411,7 @@ const initializeClient = async ({ // SDK rebuilds a graph for approval resume. The SDK event's breaker signal // is composed with this authoritative job signal by the handler. runSignal: signal, - foregroundRunId: runtimeRequestBody?.messageId, + foregroundRunId, ordinaryToolCancellation: ordinaryToolCancellationEnabled, loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => { const ctx = agentToolContexts.get(agentId) ?? {}; diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index ade30c146f0..bfe09ccb391 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -308,12 +308,15 @@ describe('initializeClient — processAgent ACL gate', () => { it('binds foreground tool execution to the host-owned run signal', async () => { mockInitializeAgent.mockResolvedValue(makePrimaryConfig([])); const controller = new AbortController(); + const req = makeReq(); + req.body.messageId = 'user-message-1'; await initializeClient({ - req: makeReq(), + req, res: {}, signal: controller.signal, endpointOption: makeEndpointOption(), + foregroundRunId: 'response-1', requestBody: { messageId: 'response-1', conversationId: 'conv_1' }, }); diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 222df84994b..a1351a02a68 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -490,6 +490,38 @@ describe('createToolExecuteHandler', () => { expect(result.status).toBe('success'); }); + it('does not bind a tagged child run when the foreground identity is unavailable', async () => { + const foregroundController = new AbortController(); + const childController = new AbortController(); + foregroundController.abort(); + const tool = { + name: 'child_tool', + invoke: jest.fn(async (_args: unknown, _config: Record) => ({ + content: 'done', + })), + }; + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: [tool] as never[], + })); + const handler = createToolExecuteHandler({ + loadTools, + runSignal: foregroundController.signal, + }); + + const [result] = await new Promise((resolve, reject) => { + handler.handle('on_tool_execute', { + toolCalls: [{ id: 'call-1', name: tool.name, args: {} }] as ToolCallRequest[], + metadata: { run_id: 'detached-child-run' }, + signal: childController.signal, + resolve, + reject, + } as ToolExecuteBatchRequest); + }); + + expect(tool.invoke.mock.calls[0][1].signal).toBe(childController.signal); + expect(result.status).toBe('success'); + }); + it('logs a cancelled tool call as debug rather than a tool error', async () => { const errorSpy = jest.spyOn(logger, 'error').mockReturnValue(logger); const controller = new AbortController(); diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 5345067ae52..aaeaa8ee71b 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -5233,9 +5233,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand eventRunId = configurable.run_id; } const foregroundHostSignal = - foregroundRunId == null || eventRunId == null || eventRunId === foregroundRunId - ? hostRunSignal - : undefined; + eventRunId == null || eventRunId === foregroundRunId ? hostRunSignal : undefined; const runSignal = foregroundHostSignal != null && eventRunSignal != null &&