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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 18 additions & 14 deletions api/server/controllers/agents/__tests__/resume.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions api/server/controllers/agents/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
18 changes: 10 additions & 8 deletions api/server/controllers/agents/resume.js
Original file line number Diff line number Diff line change
Expand Up @@ -1815,21 +1815,23 @@ 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,
endpointOption: req.body.endpointOption,
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;

Expand Down
4 changes: 3 additions & 1 deletion api/server/services/Endpoints/agents/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand All @@ -174,6 +175,7 @@ const initializeClient = async ({
endpointOption,
jobCreatedAt,
checkpointNamespace,
foregroundRunId,
requestBody,
}) => {
if (!endpointOption) {
Expand Down Expand Up @@ -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) ?? {};
Expand Down
5 changes: 4 additions & 1 deletion api/server/services/Endpoints/agents/initialize.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});

Expand Down
32 changes: 32 additions & 0 deletions packages/api/src/agents/handlers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
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<ToolExecuteResult[]>((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();
Expand Down
4 changes: 1 addition & 3 deletions packages/api/src/agents/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
Loading