mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🪢 refactor: Move Agent Execution Seam Before Initialization (#14581)
* refactor: move agent execution seam before initialization * refactor: type librechat agent request extensions * refactor: read envelope values from descriptors * fix: preserve envelope types and validation errors * fix: bound agent envelope traversal
This commit is contained in:
parent
c2d8252b4f
commit
59395a6bf0
8 changed files with 683 additions and 59 deletions
3
CONTEXT.md
Normal file
3
CONTEXT.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# 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.
|
||||
|
|
@ -11,6 +11,26 @@ const mockRecordCollectedUsage = jest
|
|||
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
||||
const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
|
||||
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
|
||||
class MockAgentRunEnvelopeError extends TypeError {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'AgentRunEnvelopeError';
|
||||
}
|
||||
}
|
||||
const mockCreateAgentRunEnvelope = jest.fn(
|
||||
({ protocol, requestId, receivedAt, principal, payload }) => ({
|
||||
version: 1,
|
||||
protocol,
|
||||
requestId,
|
||||
receivedAt,
|
||||
principal: {
|
||||
userId: principal.id,
|
||||
...(principal.role != null && { role: principal.role }),
|
||||
...(principal.tenantId != null && { tenantId: principal.tenantId }),
|
||||
},
|
||||
payload: JSON.parse(JSON.stringify(payload)),
|
||||
}),
|
||||
);
|
||||
const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
|
||||
const primed = {};
|
||||
for (const skill of alwaysApplySkillPrimes ?? []) {
|
||||
|
|
@ -88,6 +108,8 @@ jest.mock('@librechat/api', () => ({
|
|||
}),
|
||||
createChunk: jest.fn().mockReturnValue({}),
|
||||
buildToolSet: jest.fn().mockReturnValue(new Set()),
|
||||
AgentRunEnvelopeError: MockAgentRunEnvelopeError,
|
||||
createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args),
|
||||
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
|
||||
resolveAgentScopedSkillIds: jest
|
||||
.fn()
|
||||
|
|
@ -331,6 +353,60 @@ describe('OpenAIChatCompletionController', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('execution envelope', () => {
|
||||
it('creates the portable run input before agent initialization', async () => {
|
||||
req.user = {
|
||||
id: 'user-123',
|
||||
role: 'USER',
|
||||
tenantId: 'tenant-123',
|
||||
federatedTokens: { access_token: 'secret' },
|
||||
};
|
||||
const requestBody = {
|
||||
...req.body,
|
||||
ephemeralAgent: { skills: true },
|
||||
manualSkills: ['review-code'],
|
||||
timezone: 'America/New_York',
|
||||
};
|
||||
req.body = requestBody;
|
||||
const { validateRequest, initializeAgent } = require('@librechat/api');
|
||||
validateRequest.mockReturnValueOnce({ request: requestBody });
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(mockCreateAgentRunEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
protocol: 'chat.completions',
|
||||
principal: req.user,
|
||||
payload: requestBody,
|
||||
requestId: expect.any(String),
|
||||
receivedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(mockCreateAgentRunEnvelope.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
initializeAgent.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(req.body).not.toBe(requestBody);
|
||||
expect(req.body).toEqual(requestBody);
|
||||
expect(JSON.stringify(mockCreateAgentRunEnvelope.mock.results[0].value)).not.toContain(
|
||||
'secret',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a protocol 400 when the envelope rejects a non-JSON payload', async () => {
|
||||
const message = 'payload.max_tokens must contain only finite numbers';
|
||||
const { createErrorResponse, initializeAgent } = require('@librechat/api');
|
||||
mockCreateAgentRunEnvelope.mockImplementationOnce(() => {
|
||||
throw new MockAgentRunEnvelopeError(message);
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(createErrorResponse).toHaveBeenCalledWith(message, 'invalid_request_error', null);
|
||||
expect(initializeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('token usage recording', () => {
|
||||
it('should call recordCollectedUsage after successful non-streaming completion', async () => {
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,26 @@ const mockRecordCollectedUsage = jest
|
|||
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
||||
const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
|
||||
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
|
||||
class MockAgentRunEnvelopeError extends TypeError {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'AgentRunEnvelopeError';
|
||||
}
|
||||
}
|
||||
const mockCreateAgentRunEnvelope = jest.fn(
|
||||
({ protocol, requestId, receivedAt, principal, payload }) => ({
|
||||
version: 1,
|
||||
protocol,
|
||||
requestId,
|
||||
receivedAt,
|
||||
principal: {
|
||||
userId: principal.id,
|
||||
...(principal.role != null && { role: principal.role }),
|
||||
...(principal.tenantId != null && { tenantId: principal.tenantId }),
|
||||
},
|
||||
payload: JSON.parse(JSON.stringify(payload)),
|
||||
}),
|
||||
);
|
||||
const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
|
||||
const primed = {};
|
||||
for (const skill of alwaysApplySkillPrimes ?? []) {
|
||||
|
|
@ -93,6 +113,8 @@ jest.mock('@librechat/api', () => ({
|
|||
}),
|
||||
applyContextToAgent: (...args) => mockApplyContextToAgent(...args),
|
||||
buildToolSet: jest.fn().mockReturnValue(new Set()),
|
||||
AgentRunEnvelopeError: MockAgentRunEnvelopeError,
|
||||
createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args),
|
||||
buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args),
|
||||
buildAgentContextAttachmentsByAgentId: (...args) =>
|
||||
mockBuildAgentContextAttachmentsByAgentId(...args),
|
||||
|
|
@ -319,6 +341,60 @@ describe('createResponse controller', () => {
|
|||
};
|
||||
});
|
||||
|
||||
describe('execution envelope', () => {
|
||||
it('creates the portable run input before agent initialization', async () => {
|
||||
req.user = {
|
||||
id: 'user-123',
|
||||
role: 'USER',
|
||||
tenantId: 'tenant-123',
|
||||
federatedTokens: { access_token: 'secret' },
|
||||
};
|
||||
const requestBody = {
|
||||
...req.body,
|
||||
ephemeralAgent: { skills: true },
|
||||
manualSkills: ['review-code'],
|
||||
timezone: 'America/New_York',
|
||||
isTemporary: true,
|
||||
};
|
||||
req.body = requestBody;
|
||||
const { validateResponseRequest, initializeAgent } = require('@librechat/api');
|
||||
validateResponseRequest.mockReturnValueOnce({ request: requestBody });
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(mockCreateAgentRunEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
protocol: 'responses',
|
||||
principal: req.user,
|
||||
payload: requestBody,
|
||||
requestId: expect.any(String),
|
||||
receivedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(mockCreateAgentRunEnvelope.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
initializeAgent.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(req.body).not.toBe(requestBody);
|
||||
expect(req.body).toEqual(requestBody);
|
||||
expect(JSON.stringify(mockCreateAgentRunEnvelope.mock.results[0].value)).not.toContain(
|
||||
'secret',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a protocol 400 when the envelope rejects a non-JSON payload', async () => {
|
||||
const message = 'payload.max_output_tokens must contain only finite numbers';
|
||||
const { sendResponsesErrorResponse, initializeAgent } = require('@librechat/api');
|
||||
mockCreateAgentRunEnvelope.mockImplementationOnce(() => {
|
||||
throw new MockAgentRunEnvelopeError(message);
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(sendResponsesErrorResponse).toHaveBeenCalledWith(res, 400, message, 'invalid_request');
|
||||
expect(initializeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation ownership validation', () => {
|
||||
it('should skip ownership check when previous_response_id is not provided', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ const {
|
|||
createRun,
|
||||
createChunk,
|
||||
buildToolSet,
|
||||
AgentRunEnvelopeError,
|
||||
createAgentRunEnvelope,
|
||||
loadSkillStates,
|
||||
sendFinalChunk,
|
||||
createSafeUser,
|
||||
|
|
@ -149,29 +151,20 @@ function sendErrorResponse(res, statusCode, message, type = 'invalid_request_err
|
|||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible chat completions controller for agents.
|
||||
* Runs a validated chat-completions envelope in the current process.
|
||||
* Express remains runtime-only state while the envelope is the portable run input.
|
||||
*
|
||||
* POST /v1/chat/completions
|
||||
*
|
||||
* Request format:
|
||||
* {
|
||||
* "model": "agent_id_here",
|
||||
* "messages": [{"role": "user", "content": "Hello!"}],
|
||||
* "stream": true,
|
||||
* "conversation_id": "optional",
|
||||
* "parent_message_id": "optional"
|
||||
* }
|
||||
* @param {import('@librechat/api').ChatCompletionRunEnvelope} envelope
|
||||
* @param {{req: import('express').Request, res: import('express').Response}} runtime
|
||||
*/
|
||||
const OpenAIChatCompletionController = async (req, res) => {
|
||||
const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
|
||||
const appConfig = req.config;
|
||||
const requestStartTime = Date.now();
|
||||
|
||||
const validation = validateRequest(req.body);
|
||||
if (isChatCompletionValidationFailure(validation)) {
|
||||
return sendErrorResponse(res, 400, validation.error);
|
||||
}
|
||||
|
||||
const request = validation.request;
|
||||
const requestStartTime = envelope.receivedAt;
|
||||
const request = envelope.payload;
|
||||
const { principal } = envelope;
|
||||
// The local executor keeps the current Express-dependent initialization path,
|
||||
// but all request-body reads now observe the detached envelope payload.
|
||||
req.body = request;
|
||||
const agentId = request.model;
|
||||
|
||||
// Look up the agent
|
||||
|
|
@ -232,7 +225,7 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
'invalid_request_error',
|
||||
);
|
||||
}
|
||||
if (!(await db.getConvo(req.user?.id, request.conversation_id))) {
|
||||
if (!(await db.getConvo(principal.userId, request.conversation_id))) {
|
||||
return sendErrorResponse(res, 404, 'Conversation not found', 'invalid_request_error');
|
||||
}
|
||||
}
|
||||
|
|
@ -277,12 +270,12 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
|
||||
const enabledCapabilities = new Set(agentsEConfig?.capabilities);
|
||||
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
|
||||
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
|
||||
const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true;
|
||||
const accessibleSkillIds = skillsCapabilityEnabled
|
||||
? withDeploymentSkillIds(
|
||||
await findAccessibleResources({
|
||||
userId: req.user.id,
|
||||
role: req.user.role,
|
||||
userId: principal.userId,
|
||||
role: principal.role,
|
||||
resourceType: ResourceType.SKILL,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
}),
|
||||
|
|
@ -290,8 +283,8 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
: [];
|
||||
const editableSkillIds = skillsCapabilityEnabled
|
||||
? await findAccessibleResources({
|
||||
userId: req.user.id,
|
||||
role: req.user.role,
|
||||
userId: principal.userId,
|
||||
role: principal.role,
|
||||
resourceType: ResourceType.SKILL,
|
||||
requiredPermissions: PermissionBits.EDIT,
|
||||
})
|
||||
|
|
@ -301,13 +294,13 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
: false;
|
||||
|
||||
const { skillStates, defaultActiveOnShare } = await loadSkillStates({
|
||||
userId: req.user.id,
|
||||
userId: principal.userId,
|
||||
appConfig,
|
||||
getUserById: db.getUserById,
|
||||
accessibleSkillIds,
|
||||
});
|
||||
|
||||
const manualSkills = extractManualSkills(req.body);
|
||||
const manualSkills = extractManualSkills(request);
|
||||
|
||||
const primaryScopedSkillIds = resolveAgentScopedSkillIds({
|
||||
agent,
|
||||
|
|
@ -741,7 +734,7 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
};
|
||||
|
||||
// Create and run the agent
|
||||
const userId = req.user?.id ?? 'api-user';
|
||||
const userId = principal.userId;
|
||||
|
||||
// Extract merged userMCPAuthMap (needed for MCP tool connections across
|
||||
// the primary and any discovered handoff sub-agents)
|
||||
|
|
@ -764,7 +757,7 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
conversationId,
|
||||
},
|
||||
user: { id: userId },
|
||||
tenantId: req.user?.tenantId,
|
||||
tenantId: principal.tenantId,
|
||||
/** Bills subagent child-run model calls (reported outside the
|
||||
* streamEvents loop) into the same collectedUsage array. */
|
||||
subagentUsageSink: createSubagentUsageSink(collectedUsage),
|
||||
|
|
@ -896,6 +889,38 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenAI-compatible chat completions ingress adapter for agents.
|
||||
* Authentication and remote-agent authorization have already run in route middleware.
|
||||
*
|
||||
* POST /v1/chat/completions
|
||||
*/
|
||||
const OpenAIChatCompletionController = async (req, res) => {
|
||||
const receivedAt = Date.now();
|
||||
const validation = validateRequest(req.body);
|
||||
if (isChatCompletionValidationFailure(validation)) {
|
||||
return sendErrorResponse(res, 400, validation.error);
|
||||
}
|
||||
|
||||
let envelope;
|
||||
try {
|
||||
envelope = createAgentRunEnvelope({
|
||||
protocol: 'chat.completions',
|
||||
requestId: req.requestId ?? req.id ?? `agent-run-${nanoid()}`,
|
||||
receivedAt,
|
||||
principal: req.user,
|
||||
payload: validation.request,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgentRunEnvelopeError) {
|
||||
return sendErrorResponse(res, 400, error.message, 'invalid_request_error');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return executeOpenAIChatCompletion(envelope, { req, res });
|
||||
};
|
||||
|
||||
/**
|
||||
* List available agents as models (filtered by remote access permissions)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ const {
|
|||
createRun,
|
||||
applyContextToAgent,
|
||||
buildToolSet,
|
||||
AgentRunEnvelopeError,
|
||||
createAgentRunEnvelope,
|
||||
buildAgentScopedContext,
|
||||
buildAgentContextAttachmentsByAgentId,
|
||||
createSafeUser,
|
||||
|
|
@ -284,25 +286,20 @@ function convertMessagesToOutputItems(messages) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create Response - POST /v1/responses
|
||||
* Runs a validated Responses envelope in the current process.
|
||||
* Express remains runtime-only state while the envelope is the portable run input.
|
||||
*
|
||||
* Creates a model response following the Open Responses API specification.
|
||||
* Supports both streaming and non-streaming responses.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('@librechat/api').ResponsesRunEnvelope} envelope
|
||||
* @param {{req: import('express').Request, res: import('express').Response}} runtime
|
||||
*/
|
||||
const createResponse = async (req, res) => {
|
||||
const executeResponse = async (envelope, { req, res }) => {
|
||||
const appConfig = req.config;
|
||||
const requestStartTime = Date.now();
|
||||
|
||||
// Validate request
|
||||
const validation = validateResponseRequest(req.body);
|
||||
if (isValidationFailure(validation)) {
|
||||
return sendResponsesErrorResponse(res, 400, validation.error);
|
||||
}
|
||||
|
||||
const request = validation.request;
|
||||
const requestStartTime = envelope.receivedAt;
|
||||
const request = envelope.payload;
|
||||
const { principal } = envelope;
|
||||
// The local executor keeps the current Express-dependent initialization path,
|
||||
// but all request-body reads now observe the detached envelope payload.
|
||||
req.body = request;
|
||||
const agentId = request.model;
|
||||
const isStreaming = request.stream === true;
|
||||
const summarizationConfig = appConfig?.summarization;
|
||||
|
|
@ -348,7 +345,7 @@ const createResponse = async (req, res) => {
|
|||
'invalid_request',
|
||||
);
|
||||
}
|
||||
if (!(await db.getConvo(req.user?.id, request.previous_response_id))) {
|
||||
if (!(await db.getConvo(principal.userId, request.previous_response_id))) {
|
||||
return sendResponsesErrorResponse(res, 404, 'Conversation not found', 'not_found');
|
||||
}
|
||||
}
|
||||
|
|
@ -397,12 +394,12 @@ const createResponse = async (req, res) => {
|
|||
appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities,
|
||||
);
|
||||
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
|
||||
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
|
||||
const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true;
|
||||
const accessibleSkillIds = skillsCapabilityEnabled
|
||||
? withDeploymentSkillIds(
|
||||
await findAccessibleResources({
|
||||
userId: req.user.id,
|
||||
role: req.user.role,
|
||||
userId: principal.userId,
|
||||
role: principal.role,
|
||||
resourceType: ResourceType.SKILL,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
}),
|
||||
|
|
@ -410,8 +407,8 @@ const createResponse = async (req, res) => {
|
|||
: [];
|
||||
const editableSkillIds = skillsCapabilityEnabled
|
||||
? await findAccessibleResources({
|
||||
userId: req.user.id,
|
||||
role: req.user.role,
|
||||
userId: principal.userId,
|
||||
role: principal.role,
|
||||
resourceType: ResourceType.SKILL,
|
||||
requiredPermissions: PermissionBits.EDIT,
|
||||
})
|
||||
|
|
@ -421,13 +418,13 @@ const createResponse = async (req, res) => {
|
|||
: false;
|
||||
|
||||
const { skillStates, defaultActiveOnShare } = await loadSkillStates({
|
||||
userId: req.user.id,
|
||||
userId: principal.userId,
|
||||
appConfig,
|
||||
getUserById: db.getUserById,
|
||||
accessibleSkillIds,
|
||||
});
|
||||
|
||||
const manualSkills = extractManualSkills(req.body);
|
||||
const manualSkills = extractManualSkills(request);
|
||||
|
||||
const primaryScopedSkillIds = resolveAgentScopedSkillIds({
|
||||
agent,
|
||||
|
|
@ -611,7 +608,7 @@ const createResponse = async (req, res) => {
|
|||
// Load previous messages if previous_response_id is provided
|
||||
let previousMessages = [];
|
||||
if (request.previous_response_id) {
|
||||
const userId = req.user?.id ?? 'api-user';
|
||||
const userId = principal.userId;
|
||||
previousMessages = await loadPreviousMessages(request.previous_response_id, userId);
|
||||
}
|
||||
|
||||
|
|
@ -777,7 +774,7 @@ const createResponse = async (req, res) => {
|
|||
};
|
||||
|
||||
// Create and run the agent
|
||||
const userId = req.user?.id ?? 'api-user';
|
||||
const userId = principal.userId;
|
||||
const userMCPAuthMap = mergedMCPAuthMap;
|
||||
|
||||
const run = await createRun({
|
||||
|
|
@ -795,7 +792,7 @@ const createResponse = async (req, res) => {
|
|||
conversationId,
|
||||
},
|
||||
user: { id: userId },
|
||||
tenantId: req.user?.tenantId,
|
||||
tenantId: principal.tenantId,
|
||||
/** Bills subagent child-run model calls (reported outside the
|
||||
* streamEvents loop) into the same collectedUsage array. */
|
||||
subagentUsageSink: createSubagentUsageSink(collectedUsage),
|
||||
|
|
@ -958,7 +955,7 @@ const createResponse = async (req, res) => {
|
|||
: {}),
|
||||
};
|
||||
|
||||
const userId = req.user?.id ?? 'api-user';
|
||||
const userId = principal.userId;
|
||||
const userMCPAuthMap = mergedMCPAuthMap;
|
||||
|
||||
const run = await createRun({
|
||||
|
|
@ -976,7 +973,7 @@ const createResponse = async (req, res) => {
|
|||
conversationId,
|
||||
},
|
||||
user: { id: userId },
|
||||
tenantId: req.user?.tenantId,
|
||||
tenantId: principal.tenantId,
|
||||
/** Bills subagent child-run model calls (reported outside the
|
||||
* streamEvents loop) into the same collectedUsage array. */
|
||||
subagentUsageSink: createSubagentUsageSink(collectedUsage),
|
||||
|
|
@ -1090,6 +1087,41 @@ const createResponse = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open Responses ingress adapter for agents.
|
||||
* Authentication and remote-agent authorization have already run in route middleware.
|
||||
*
|
||||
* POST /v1/responses
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
const createResponse = async (req, res) => {
|
||||
const receivedAt = Date.now();
|
||||
const validation = validateResponseRequest(req.body);
|
||||
if (isValidationFailure(validation)) {
|
||||
return sendResponsesErrorResponse(res, 400, validation.error);
|
||||
}
|
||||
|
||||
let envelope;
|
||||
try {
|
||||
envelope = createAgentRunEnvelope({
|
||||
protocol: 'responses',
|
||||
requestId: req.requestId ?? req.id ?? `agent-run-${nanoid()}`,
|
||||
receivedAt,
|
||||
principal: req.user,
|
||||
payload: validation.request,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgentRunEnvelopeError) {
|
||||
return sendResponsesErrorResponse(res, 400, error.message, 'invalid_request');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return executeResponse(envelope, { req, res });
|
||||
};
|
||||
|
||||
/**
|
||||
* List available agents as models - GET /v1/models (also works with /v1/responses/models)
|
||||
*
|
||||
|
|
|
|||
162
packages/api/src/agents/envelope.spec.ts
Normal file
162
packages/api/src/agents/envelope.spec.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import {
|
||||
AGENT_RUN_ENVELOPE_MAX_NESTING_DEPTH,
|
||||
AGENT_RUN_ENVELOPE_VERSION,
|
||||
AgentRunEnvelopeError,
|
||||
createAgentRunEnvelope,
|
||||
} from './envelope';
|
||||
|
||||
describe('createAgentRunEnvelope', () => {
|
||||
const createBaseInput = () => ({
|
||||
protocol: 'chat.completions' as const,
|
||||
requestId: 'req-123',
|
||||
receivedAt: 1_725_000_000_000,
|
||||
principal: {
|
||||
id: 'user-123',
|
||||
role: 'USER',
|
||||
tenantId: 'tenant-123',
|
||||
password: 'must-not-cross',
|
||||
federatedTokens: { access_token: 'must-not-cross' },
|
||||
},
|
||||
payload: {
|
||||
model: 'agent-123',
|
||||
messages: [{ role: 'user' as const, content: 'Hello' }],
|
||||
stream: true,
|
||||
ephemeralAgent: { skills: true },
|
||||
manualSkills: ['review-code'],
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
});
|
||||
|
||||
it('creates a versioned JSON envelope with only the trusted principal projection', () => {
|
||||
const envelope = createAgentRunEnvelope(createBaseInput());
|
||||
|
||||
expect(envelope).toEqual({
|
||||
version: AGENT_RUN_ENVELOPE_VERSION,
|
||||
protocol: 'chat.completions',
|
||||
requestId: 'req-123',
|
||||
receivedAt: 1_725_000_000_000,
|
||||
principal: {
|
||||
userId: 'user-123',
|
||||
role: 'USER',
|
||||
tenantId: 'tenant-123',
|
||||
},
|
||||
payload: {
|
||||
model: 'agent-123',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: true,
|
||||
ephemeralAgent: { skills: true },
|
||||
manualSkills: ['review-code'],
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(JSON.stringify(envelope))).toEqual(envelope);
|
||||
expect(JSON.stringify(envelope)).not.toContain('must-not-cross');
|
||||
expect(envelope.payload.ephemeralAgent?.skills).toBe(true);
|
||||
});
|
||||
|
||||
it('detaches the payload from the Express request body', () => {
|
||||
const input = createBaseInput();
|
||||
const envelope = createAgentRunEnvelope(input);
|
||||
input.payload.messages[0].content = 'Changed after dispatch';
|
||||
|
||||
expect(envelope.payload.messages[0].content).toBe('Hello');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['function', () => undefined],
|
||||
['undefined', undefined],
|
||||
['bigint', BigInt(1)],
|
||||
['symbol', Symbol('value')],
|
||||
['non-finite number', Number.NaN],
|
||||
['positive infinity', Number.POSITIVE_INFINITY],
|
||||
['class instance', new Date()],
|
||||
])('rejects a %s in the payload', (_label, value) => {
|
||||
expect(() =>
|
||||
createAgentRunEnvelope({
|
||||
...createBaseInput(),
|
||||
payload: { ...createBaseInput().payload, unsafe: value },
|
||||
} as unknown as Parameters<typeof createAgentRunEnvelope>[0]),
|
||||
).toThrow(AgentRunEnvelopeError);
|
||||
});
|
||||
|
||||
it('rejects circular payloads', () => {
|
||||
const payload: Record<string, unknown> = { ...createBaseInput().payload };
|
||||
payload.circular = payload;
|
||||
|
||||
expect(() =>
|
||||
createAgentRunEnvelope({
|
||||
...createBaseInput(),
|
||||
payload,
|
||||
} as unknown as Parameters<typeof createAgentRunEnvelope>[0]),
|
||||
).toThrow('payload.circular contains a circular reference');
|
||||
});
|
||||
|
||||
it('rejects payloads that exceed the bounded nesting depth', () => {
|
||||
let nested: unknown = 'value';
|
||||
for (let depth = 0; depth <= AGENT_RUN_ENVELOPE_MAX_NESTING_DEPTH; depth++) {
|
||||
nested = [nested];
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
createAgentRunEnvelope({
|
||||
...createBaseInput(),
|
||||
payload: { ...createBaseInput().payload, nested },
|
||||
} as unknown as Parameters<typeof createAgentRunEnvelope>[0]),
|
||||
).toThrow(`exceeds the maximum nesting depth of ${AGENT_RUN_ENVELOPE_MAX_NESTING_DEPTH}`);
|
||||
});
|
||||
|
||||
it('rejects sparse arrays and hidden object state', () => {
|
||||
const sparse = Array<string>(1);
|
||||
expect(() =>
|
||||
createAgentRunEnvelope({
|
||||
...createBaseInput(),
|
||||
payload: { ...createBaseInput().payload, sparse },
|
||||
} as unknown as Parameters<typeof createAgentRunEnvelope>[0]),
|
||||
).toThrow('payload.sparse contains sparse array entries');
|
||||
|
||||
const payload = { ...createBaseInput().payload };
|
||||
Object.defineProperty(payload, 'hidden', { enumerable: false, value: 'state' });
|
||||
expect(() => createAgentRunEnvelope({ ...createBaseInput(), payload })).toThrow(
|
||||
'payload.hidden must be an enumerable property',
|
||||
);
|
||||
});
|
||||
|
||||
it('supports the Responses protocol as a discriminated envelope', () => {
|
||||
const payload = {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
isTemporary: true,
|
||||
manualSkills: ['review-code'],
|
||||
};
|
||||
const envelope = createAgentRunEnvelope({
|
||||
protocol: 'responses',
|
||||
requestId: 'req-responses',
|
||||
receivedAt: 1_725_000_000_001,
|
||||
principal: { id: 'user-123' },
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(envelope.protocol).toBe('responses');
|
||||
expect(envelope.payload).toEqual(payload);
|
||||
expect(envelope.payload.isTemporary).toBe(true);
|
||||
});
|
||||
|
||||
it('requires an authenticated user id', () => {
|
||||
expect(() =>
|
||||
createAgentRunEnvelope({
|
||||
...createBaseInput(),
|
||||
principal: undefined,
|
||||
}),
|
||||
).toThrow('principal.id must be a non-empty string');
|
||||
});
|
||||
|
||||
it('rejects unknown protocol tags at runtime', () => {
|
||||
expect(() =>
|
||||
createAgentRunEnvelope({
|
||||
...createBaseInput(),
|
||||
protocol: 'assistants',
|
||||
} as unknown as Parameters<typeof createAgentRunEnvelope>[0]),
|
||||
).toThrow('Unsupported agent run protocol: assistants');
|
||||
});
|
||||
});
|
||||
249
packages/api/src/agents/envelope.ts
Normal file
249
packages/api/src/agents/envelope.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import type { TEphemeralAgent } from 'librechat-data-provider';
|
||||
import type { ChatCompletionRequest } from './openai';
|
||||
import type { ResponseRequest } from './responses';
|
||||
|
||||
export const AGENT_RUN_ENVELOPE_VERSION = 1 as const;
|
||||
export const AGENT_RUN_ENVELOPE_MAX_NESTING_DEPTH = 64;
|
||||
|
||||
export type AgentRunProtocol = 'chat.completions' | 'responses';
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export interface AgentRunPrincipal {
|
||||
userId: string;
|
||||
role?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface AgentRunPrincipalInput {
|
||||
id?: string;
|
||||
role?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
/** LibreChat request fields consumed by execution but not declared by the public protocols. */
|
||||
export interface AgentRunPayloadExtensions {
|
||||
ephemeralAgent?: TEphemeralAgent | null;
|
||||
manualSkills?: string[];
|
||||
timezone?: string;
|
||||
isTemporary?: boolean;
|
||||
}
|
||||
|
||||
export type ChatCompletionRunPayload = ChatCompletionRequest & AgentRunPayloadExtensions;
|
||||
export type ResponsesRunPayload = ResponseRequest & AgentRunPayloadExtensions;
|
||||
|
||||
interface AgentRunEnvelopeBase {
|
||||
version: typeof AGENT_RUN_ENVELOPE_VERSION;
|
||||
requestId: string;
|
||||
receivedAt: number;
|
||||
principal: AgentRunPrincipal;
|
||||
}
|
||||
|
||||
export interface ChatCompletionRunEnvelope extends AgentRunEnvelopeBase {
|
||||
protocol: 'chat.completions';
|
||||
payload: ChatCompletionRunPayload;
|
||||
}
|
||||
|
||||
export interface ResponsesRunEnvelope extends AgentRunEnvelopeBase {
|
||||
protocol: 'responses';
|
||||
payload: ResponsesRunPayload;
|
||||
}
|
||||
|
||||
export type AgentRunEnvelope = ChatCompletionRunEnvelope | ResponsesRunEnvelope;
|
||||
|
||||
export type CreateAgentRunEnvelopeInput =
|
||||
| {
|
||||
protocol: 'chat.completions';
|
||||
requestId: string;
|
||||
receivedAt: number;
|
||||
principal: AgentRunPrincipalInput | null | undefined;
|
||||
payload: ChatCompletionRunPayload;
|
||||
}
|
||||
| {
|
||||
protocol: 'responses';
|
||||
requestId: string;
|
||||
receivedAt: number;
|
||||
principal: AgentRunPrincipalInput | null | undefined;
|
||||
payload: ResponsesRunPayload;
|
||||
};
|
||||
|
||||
type CreateChatCompletionRunEnvelopeInput = Extract<
|
||||
CreateAgentRunEnvelopeInput,
|
||||
{ protocol: 'chat.completions' }
|
||||
>;
|
||||
type CreateResponsesRunEnvelopeInput = Extract<
|
||||
CreateAgentRunEnvelopeInput,
|
||||
{ protocol: 'responses' }
|
||||
>;
|
||||
|
||||
export class AgentRunEnvelopeError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AgentRunEnvelopeError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonEmptyString(value: string | undefined, path: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new AgentRunEnvelopeError(`${path} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T, path: string, ancestors: WeakSet<object>, depth: number): T;
|
||||
function cloneJsonValue(
|
||||
value: unknown,
|
||||
path: string,
|
||||
ancestors: WeakSet<object>,
|
||||
depth: number,
|
||||
): unknown {
|
||||
if (depth > AGENT_RUN_ENVELOPE_MAX_NESTING_DEPTH) {
|
||||
throw new AgentRunEnvelopeError(
|
||||
`${path} exceeds the maximum nesting depth of ${AGENT_RUN_ENVELOPE_MAX_NESTING_DEPTH}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new AgentRunEnvelopeError(`${path} must contain only finite numbers`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
throw new AgentRunEnvelopeError(`${path} contains a non-JSON ${typeof value} value`);
|
||||
}
|
||||
|
||||
if (ancestors.has(value)) {
|
||||
throw new AgentRunEnvelopeError(`${path} contains a circular reference`);
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
|
||||
try {
|
||||
const symbolKeys = Object.getOwnPropertySymbols(value);
|
||||
if (symbolKeys.length > 0) {
|
||||
throw new AgentRunEnvelopeError(`${path} contains symbol keys`);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const cloned: unknown[] = new Array(value.length);
|
||||
let clonedItemCount = 0;
|
||||
for (const key of Object.getOwnPropertyNames(value)) {
|
||||
if (key === 'length') {
|
||||
continue;
|
||||
}
|
||||
const index = Number(key);
|
||||
if (
|
||||
!Number.isSafeInteger(index) ||
|
||||
index < 0 ||
|
||||
index >= value.length ||
|
||||
String(index) !== key
|
||||
) {
|
||||
throw new AgentRunEnvelopeError(`${path} contains non-index array properties`);
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
||||
throw new AgentRunEnvelopeError(`${path}[${index}] must not be an accessor property`);
|
||||
}
|
||||
const itemValue: unknown = descriptor.value;
|
||||
cloned[index] = cloneJsonValue(itemValue, `${path}[${index}]`, ancestors, depth + 1);
|
||||
clonedItemCount++;
|
||||
}
|
||||
if (clonedItemCount !== value.length) {
|
||||
throw new AgentRunEnvelopeError(`${path} contains sparse array entries`);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
const typeName = value.constructor?.name ?? 'object';
|
||||
throw new AgentRunEnvelopeError(`${path} contains a non-plain ${typeName} value`);
|
||||
}
|
||||
|
||||
const cloned: { [key: string]: unknown } = {};
|
||||
for (const key of Object.getOwnPropertyNames(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor?.enumerable !== true) {
|
||||
throw new AgentRunEnvelopeError(`${path}.${key} must be an enumerable property`);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
||||
throw new AgentRunEnvelopeError(`${path}.${key} must not be an accessor property`);
|
||||
}
|
||||
const propertyValue: unknown = descriptor.value;
|
||||
Object.defineProperty(cloned, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: cloneJsonValue(propertyValue, `${path}.${key}`, ancestors, depth + 1),
|
||||
});
|
||||
}
|
||||
return cloned;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function createPrincipal(input: AgentRunPrincipalInput | null | undefined): AgentRunPrincipal {
|
||||
const userId = assertNonEmptyString(input?.id, 'principal.id');
|
||||
const principal: AgentRunPrincipal = { userId };
|
||||
|
||||
if (input?.role != null) {
|
||||
principal.role = assertNonEmptyString(input.role, 'principal.role');
|
||||
}
|
||||
if (input?.tenantId != null) {
|
||||
principal.tenantId = assertNonEmptyString(input.tenantId, 'principal.tenantId');
|
||||
}
|
||||
|
||||
return principal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the versioned, transport-safe request that crosses the agent execution seam.
|
||||
* Runtime objects, provider clients, callbacks, credentials, and Express state belong to
|
||||
* the execution host and must never be added to this envelope.
|
||||
*/
|
||||
export function createAgentRunEnvelope(
|
||||
input: CreateChatCompletionRunEnvelopeInput,
|
||||
): ChatCompletionRunEnvelope;
|
||||
export function createAgentRunEnvelope(
|
||||
input: CreateResponsesRunEnvelopeInput,
|
||||
): ResponsesRunEnvelope;
|
||||
export function createAgentRunEnvelope(input: CreateAgentRunEnvelopeInput): AgentRunEnvelope {
|
||||
const receivedProtocol: string = input.protocol;
|
||||
const requestId = assertNonEmptyString(input.requestId, 'requestId');
|
||||
if (!Number.isSafeInteger(input.receivedAt) || input.receivedAt < 0) {
|
||||
throw new AgentRunEnvelopeError('receivedAt must be a non-negative integer timestamp');
|
||||
}
|
||||
|
||||
const base = {
|
||||
version: AGENT_RUN_ENVELOPE_VERSION,
|
||||
requestId,
|
||||
receivedAt: input.receivedAt,
|
||||
principal: createPrincipal(input.principal),
|
||||
};
|
||||
|
||||
if (input.protocol === 'chat.completions') {
|
||||
return {
|
||||
...base,
|
||||
protocol: input.protocol,
|
||||
payload: cloneJsonValue(input.payload, 'payload', new WeakSet(), 0),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.protocol === 'responses') {
|
||||
return {
|
||||
...base,
|
||||
protocol: input.protocol,
|
||||
payload: cloneJsonValue(input.payload, 'payload', new WeakSet(), 0),
|
||||
};
|
||||
}
|
||||
|
||||
throw new AgentRunEnvelopeError(`Unsupported agent run protocol: ${receivedProtocol}`);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export * from './context';
|
|||
export * from './conversation';
|
||||
export * from './discovery';
|
||||
export * from './edges';
|
||||
export * from './envelope';
|
||||
export * from './handlers';
|
||||
export * from './harvest';
|
||||
export * from './initialize';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue