Skip to content
Merged
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Domain language

- **Agent run envelope**: the versioned, JSON-safe request contract created after ingress authentication and protocol validation but before agent, provider, tool, or MCP initialization. It carries only the validated protocol payload and the minimum trusted principal identifiers. The execution host rehydrates all runtime state from those identifiers.
- **MCP runtime request body**: trusted chat identifiers supplied only while an MCP server handles an agent request. It enables request-scoped header placeholders without retaining user-specific request data on a shared server definition.
- **Subagent thread**: a durable, view-only child conversation owned by one parent conversation and subagent identity. A parent agent may continue it by stable `threadId`; each continuation uses a fresh execution lease restored from the canonical child transcript. It is not an ordinary human-writable chat.
- **Live subagent task owner**: the one API process holding a detached child execution, its abort controller, and its bounded control queue. Redis may route trusted poll/control envelopes to that owner, but it does not migrate or persist the executor; Mongo persists only the logical child thread and its continuation fence.
- **Subagent completion wakeup**: a durable internal `continue` trigger pre-registered before detached child execution so a process crash cannot lose the wakeup. Delivery defers until the child's terminal transcript is persisted, targets the initiating agent and exact parent response branch, carries task metadata rather than child output, waits for the parent generation to settle, and starts the parent turn that collects the result through the existing task store.
Expand Down
12 changes: 9 additions & 3 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,16 +347,22 @@ class BaseClient {
const conversationId = requestConvoId ?? crypto.randomUUID();
const parentMessageId = opts.parentMessageId ?? Constants.NO_PARENT;
const userMessageId =
overrideUserMessageId ?? opts.overrideParentMessageId ?? crypto.randomUUID();
let responseMessageId = opts.responseMessageId ?? crypto.randomUUID();
opts.preallocatedUserMessageId ??
overrideUserMessageId ??
opts.overrideParentMessageId ??
crypto.randomUUID();
let responseMessageId =
opts.responseMessageId ?? opts.preallocatedResponseMessageId ?? crypto.randomUUID();
let head = isEdited ? responseMessageId : parentMessageId;
this.currentMessages = (await this.loadHistory(conversationId, head)) ?? [];
this.conversationId = conversationId;

if (isEdited && !isContinued) {
responseMessageId = crypto.randomUUID();
responseMessageId = opts.preallocatedResponseMessageId ?? crypto.randomUUID();
head = responseMessageId;
this.currentMessages[this.currentMessages.length - 1].messageId = head;
} else if (opts.preallocatedResponseMessageId != null) {
responseMessageId = opts.preallocatedResponseMessageId;
}

if (opts.isRegenerate && responseMessageId.endsWith('_')) {
Expand Down
15 changes: 15 additions & 0 deletions api/app/clients/specs/BaseClient.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,21 @@ describe('BaseClient', () => {
);
});

it('honors response and user message IDs preallocated before initialization', async () => {
TestClient = initializeFakeClient(apiKey, options, messageHistory);

const result = await TestClient.handleStartMethods('request-scoped MCP', {
conversationId,
parentMessageId: '3',
preallocatedUserMessageId: 'preallocated-user',
preallocatedResponseMessageId: 'preallocated-response',
});

expect(result.userMessage.messageId).toBe('preallocated-user');
expect(result.responseMessageId).toBe('preallocated-response');
expect(TestClient.responseMessageId).toBe('preallocated-response');
});

it('applies edited reasoning content from its typed payload before regeneration', async () => {
const responseMessageId = 'response-with-reasoning';
const newHistory = [
Expand Down
2 changes: 1 addition & 1 deletion api/app/clients/tools/util/handleTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@ const loadTools = async ({
user: safeUser,
userMCPAuthMap,
configServers,
requestBody: options.req?.body,
requestBody: options.requestBody ?? options.req?.body,
requestScopedConnections,
res: options.res,
streamId: options.req?._resumableStreamId || null,
Expand Down
85 changes: 84 additions & 1 deletion api/server/controllers/agents/__tests__/openai.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ jest.mock('@librechat/api', () => ({
buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions),
AgentRunEnvelopeError: MockAgentRunEnvelopeError,
createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args),
createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({
messageId,
conversationId,
...(parentMessageId !== undefined && {
parentMessageId: parentMessageId ?? '00000000-0000-0000-0000-000000000000',
}),
}),
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
resolveAgentScopedSkillIds: jest
.fn()
Expand Down Expand Up @@ -499,7 +506,10 @@ describe('OpenAIChatCompletionController', () => {
const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
await toolExecuteOptions.loadTools(['file_search'], 'agent-123');
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }),
expect.objectContaining({
agentResourceType: ResourceType.REMOTE_AGENT,
requestBody: initializeParams.requestBody,
}),
);
});

Expand Down Expand Up @@ -681,6 +691,79 @@ describe('OpenAIChatCompletionController', () => {
});

describe('recursionLimit resolution', () => {
it('threads the OpenAI parent message id through both MCP execution bodies', async () => {
const { validateRequest, createRun, initializeAgent } = require('@librechat/api');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
messages: [],
stream: false,
conversation_id: 'conversation-123',
parent_message_id: 'parent-123',
},
});
getConvo.mockResolvedValueOnce({ conversationId: 'conversation-123', user: 'user-123' });

await OpenAIChatCompletionController(req, res);

expect(initializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: {
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
parentMessageId: 'parent-123',
},
}),
expect.anything(),
);
expect(createRun).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: {
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
parentMessageId: 'parent-123',
},
}),
);
expect(mockProcessStream).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
configurable: expect.objectContaining({
requestBody: {
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
parentMessageId: 'parent-123',
},
}),
}),
expect.anything(),
);
});

it('does not synthesize an MCP parent for a continuation that omits it', async () => {
const { validateRequest, initializeAgent } = require('@librechat/api');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
messages: [],
stream: false,
conversation_id: 'conversation-123',
},
});
getConvo.mockResolvedValueOnce({ conversationId: 'conversation-123', user: 'user-123' });

await OpenAIChatCompletionController(req, res);

const requestBody = initializeAgent.mock.calls.at(-1)[0].requestBody;
expect(requestBody).toEqual({
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
});
expect(requestBody).not.toHaveProperty('parentMessageId');
});

it('should pass resolveRecursionLimit result to processStream config', async () => {
const { resolveRecursionLimit } = require('@librechat/api');
resolveRecursionLimit.mockReturnValueOnce(75);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ jest.mock('@librechat/api', () => ({
getAgentStartupTelemetry: jest.fn(() => undefined),
acceptAgentStartupTelemetry: jest.fn(),
isUnpersistedPreliminaryParent: jest.fn(async () => false),
createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({
messageId,
conversationId,
parentMessageId,
}),
}));

jest.mock('~/server/cleanup', () => ({
Expand Down
168 changes: 166 additions & 2 deletions api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ jest.mock('@librechat/api', () => ({
return messages.length === 0;
},
deleteAgentCheckpoint: (...args) => mockDeleteAgentCheckpoint(...args),
createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({
messageId,
conversationId,
parentMessageId,
}),
}));

jest.mock('~/server/cleanup', () => ({
Expand Down Expand Up @@ -383,6 +388,35 @@ describe('ResumableAgentController resume metadata', () => {
},
);

it.each(['overrideUserMessageId', 'overrideConvoId'])(
'rejects a non-string %s before admission',
async (field) => {
const req = {
user: { id: 'user-123' },
body: {
text: 'Invalid override identity',
messageId: 'user-message',
clientRequestId: 'override-request',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
[field]: { malformed: true },
},
config: {},
};
const res = { json: jest.fn(), status: jest.fn(() => res) };

await AgentController(req, res, jest.fn(), jest.fn(), null);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'INVALID_OVERRIDE_ID' }),
);
expect(mockGenerationJobManager.claimGeneration).not.toHaveBeenCalled();
expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled();
expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled();
},
);

it.each([
['empty recovery id', { clientRequestId: 'steer-recovery:' }],
['regenerate', { isRegenerate: true }],
Expand Down Expand Up @@ -695,9 +729,14 @@ describe('ResumableAgentController resume metadata', () => {
preemptCapable: true,
agent_id: undefined,
isTemporary: true,
responseMessageId: 'follow-up-user_',
responseMessageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
mcpRequestBody: {
messageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
conversationId,
parentMessageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
},
userMessage: {
messageId: 'follow-up-user',
messageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
parentMessageId: 'original-response',
conversationId,
text: 'Check Google Workspace availability.',
Expand Down Expand Up @@ -1023,6 +1062,89 @@ describe('ResumableAgentController resume metadata', () => {
);
});

it('preallocates response-scoped MCP identities before native Agent initialization', async () => {
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Use request-scoped headers.',
messageId: 'incoming-client-message',
parentMessageId: 'previous-response',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};

await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null);

expect(initializeClient).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: {
messageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
conversationId: 'conversation-123',
parentMessageId: expect.stringMatching(/^[0-9a-f-]{36}$/),
},
}),
);
const [{ requestBody }] = initializeClient.mock.calls[0];
const jobOptions = mockGenerationJobManager.createJob.mock.calls[0][3];
expect(jobOptions.initialMetadata.responseMessageId).toBe(requestBody.messageId);
expect(jobOptions.initialMetadata.userMessage.messageId).toBe(requestBody.parentMessageId);
expect(jobOptions.initialMetadata.mcpRequestBody).toBe(requestBody);
expect(requestBody.messageId).not.toBe(req.body.messageId);
});

it('uses the effective overridden conversation in the MCP request body', async () => {
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Continue in the overridden conversation.',
messageId: 'incoming-client-message',
parentMessageId: 'previous-response',
conversationId: 'source-conversation',
overrideConvoId: 'overridden-conversation__0',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};

await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null);

const [{ requestBody }] = initializeClient.mock.calls[0];
const jobOptions = mockGenerationJobManager.createJob.mock.calls[0][3];
expect(requestBody.conversationId).toBe('overridden-conversation');
expect(jobOptions.initialMetadata.mcpRequestBody).toBe(requestBody);
});

it('preallocates the replacement response as the MCP parent for edited content', async () => {
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Edited response text.',
messageId: 'existing-user-message',
responseMessageId: 'existing-response-message',
parentMessageId: 'previous-response',
overrideParentMessageId: 'existing-user-message',
editedContent: { index: 0, type: 'text', text: 'Edited response text.' },
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};

await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null);

const [{ requestBody }] = initializeClient.mock.calls[0];
const jobOptions = mockGenerationJobManager.createJob.mock.calls[0][3];
expect(requestBody.messageId).toMatch(/^[0-9a-f-]{36}$/);
expect(requestBody.parentMessageId).toBe(requestBody.messageId);
expect(requestBody.messageId).not.toBe('existing-response-message');
expect(jobOptions.initialMetadata.mcpRequestBody).toBe(requestBody);
});

it('stores model spec icon fallbacks and agent ids in early resume metadata', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
Expand Down Expand Up @@ -1080,6 +1202,48 @@ describe('ResumableAgentController resume metadata', () => {
);
});

it('records regeneration ownership for exact-ID resume reconstruction', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Regenerate the edited response.',
messageId: 'user-message',
parentMessageId: 'parent-message',
responseMessageId: 'edited-response',
isRegenerate: true,
conversationId,
endpointOption: {
endpoint: 'agents',
modelOptions: { model: 'gpt-4.1' },
},
},
config: {},
};
const res = {
headersSent: true,
json: jest.fn(() => {
res.headersSent = true;
}),
status: jest.fn(() => res),
};

await AgentController(req, res, jest.fn(), initializeClient, null);

expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith(
conversationId,
'user-123',
conversationId,
expect.objectContaining({
initialMetadata: expect.objectContaining({
responseMessageId: 'edited-response',
isRegenerate: true,
}),
}),
);
});

it('falls back to the model spec preset endpoint when no icon URL is configured', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
Expand Down
Loading
Loading