mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-03 13:12:44 +00:00
* feat: introduce optional content protection seam * feat: enforce source-aware content filters * feat: complete source-aware content enforcement * test: activate skill file-text fail-close fixtures * fix: harden source-aware content filters * fix: harden model-bound content filtering * fix: preserve legacy filters and generated files * fix: inspect shared scalar metadata * test: align mocks with current dev dependencies * feat: add persisted content filter safeguards * feat: complete source-aware content filter enforcement * fix: move resume content preflight into TypeScript * fix: close content inspection edge cases * fix: harden content protection boundaries * fix: complete content protection safeguards * test: align persisted memory filter coverage * fix: reconcile content protection with current dev * fix: reconcile content protection with latest dev * fix: close content protection review gaps * fix: enforce source-aware provider boundaries * fix: preserve legacy PII preflight semantics * test: stabilize stored branch preflight fixture * fix: defer agent writes until protected model admission * perf: harden source-aware model-bound filtering * fix: canonicalize provider lineage before validation * fix: satisfy model-bound callback type checks * perf: Bound content protection filtering work * fix: Bound submission array traversal * fix: Stabilize bounded content snapshots * fix: Scope model-bound traversal overflows * fix: Preserve scoped content inspection * fix: Accumulate aggregate traversal scopes * fix: centralize content policy boundaries * test: align deferred tool policy context * test: align controller policy mocks * style: normalize content protection imports * fix: close content policy review gaps * fix: narrow active skill policy config * fix: address content protection review boundaries * fix: retain exact provenance overflow sentinel * fix: preserve literal and scoped provenance updates * fix: narrow persisted edit provenance * fix: isolate exact overflow attribution * fix: centralize stored prompt protection * fix: fail closed on incomplete transcript evidence * fix: align canonical transcript routing * refactor: centralize content policy preflights * fix: isolate upload policy error typing * style: sort policy preflight imports * refactor: centralize content policy boundaries
1626 lines
53 KiB
JavaScript
1626 lines
53 KiB
JavaScript
const { nanoid } = require('nanoid');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
|
|
const {
|
|
EModelEndpoint,
|
|
ResourceType,
|
|
PermissionBits,
|
|
hasPermissions,
|
|
AgentCapabilities,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
createRun,
|
|
applyContextToAgent,
|
|
buildInitialToolSessions,
|
|
buildToolSet,
|
|
AgentRunEnvelopeError,
|
|
createAgentRunEnvelope,
|
|
createMCPRuntimeRequestBody,
|
|
buildAgentScopedContext,
|
|
buildInlineMemoryContext,
|
|
buildAgentContextAttachmentsByAgentId,
|
|
createSafeUser,
|
|
initializeAgent,
|
|
loadSkillStates,
|
|
getBalanceConfig,
|
|
injectSkillPrimes,
|
|
extractManualSkills,
|
|
recordCollectedUsage,
|
|
createSubagentUsageSink,
|
|
getTransactionsConfig,
|
|
resolveAgentTokenConfig,
|
|
resolveSubagentGraphs,
|
|
inspectContent,
|
|
extractAgentContent,
|
|
extractFileContent,
|
|
extractMessageContent,
|
|
extractModelParameterContent,
|
|
extractSkillContent,
|
|
extractToolArgumentContent,
|
|
contentFilterBlockResponse,
|
|
contentFilterUninspectableResponse,
|
|
discoverConnectedAgents,
|
|
getBlockedOpaqueFileField,
|
|
getContentTraversalFragments,
|
|
isContentTraversalProtected,
|
|
isContentTraversalLimitError,
|
|
prependContentTraversalFragments,
|
|
assertModelBoundContent,
|
|
hasModelBoundContentProtection,
|
|
isContentFilterError,
|
|
getSafeErrorMetadata,
|
|
createToolExecuteHandler,
|
|
getRemoteAgentPermissions,
|
|
resolveAgentScopedSkillIds,
|
|
// Responses API
|
|
writeDone,
|
|
buildResponse,
|
|
generateResponseId,
|
|
isValidationFailure,
|
|
emitResponseCreated,
|
|
createResponseContext,
|
|
createResponseTracker,
|
|
setupStreamingResponse,
|
|
emitResponseInProgress,
|
|
convertInputToMessages,
|
|
validateResponseRequest,
|
|
buildAggregatedResponse,
|
|
createResponseAggregator,
|
|
sendResponsesErrorResponse,
|
|
createResponsesEventHandlers,
|
|
createAggregatorEventHandlers,
|
|
getLangfuseTraceMessageFields,
|
|
stripActivityLabelParts,
|
|
CHILD_THREAD_READ_ONLY_ERROR,
|
|
} = require('@librechat/api');
|
|
const {
|
|
createResponsesToolEndCallback,
|
|
buildSummarizationHandlers,
|
|
markSummarizationUsage,
|
|
createToolEndCallback,
|
|
agentLogHandlerObj,
|
|
} = require('~/server/controllers/agents/callbacks');
|
|
const {
|
|
loadAgentTools,
|
|
loadToolsForExecution,
|
|
isFatalAgentInitializationError,
|
|
} = require('~/server/services/ToolService');
|
|
const {
|
|
findAccessibleResources,
|
|
getEffectivePermissions,
|
|
} = require('~/server/services/PermissionService');
|
|
const {
|
|
getSkillToolDeps,
|
|
getSkillDbMethods,
|
|
canAuthorSkillFiles,
|
|
withDeploymentSkillIds,
|
|
buildAgentToolContext,
|
|
resolveMemoryAvailability,
|
|
enrichLoadedToolsWithAgentContext,
|
|
} = require('~/server/services/Endpoints/agents/skillDeps');
|
|
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
|
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
|
const { resolveConfigServers, getAccessibleMcpServerNames } = require('~/server/services/MCP');
|
|
const { resolveConversationTitle } = require('~/server/services/Endpoints/titlePolicy');
|
|
const { getMCPManager } = require('~/config');
|
|
const { logViolation } = require('~/cache');
|
|
const db = require('~/models');
|
|
|
|
const filterFilesByRemoteAgentAccess = (params) =>
|
|
filterFilesByAgentAccess({ ...params, resourceType: ResourceType.REMOTE_AGENT });
|
|
const GENERIC_PROVIDER_ERROR = 'An error occurred while processing the request';
|
|
|
|
function getUserFacingProviderError(error, protectionEnabled) {
|
|
if (protectionEnabled) {
|
|
return GENERIC_PROVIDER_ERROR;
|
|
}
|
|
return error instanceof Error ? error.message : 'An error occurred';
|
|
}
|
|
|
|
/**
|
|
* Creates a tool loader function for the agent.
|
|
* @param {AbortSignal} signal - The abort signal
|
|
* @param {boolean} [definitionsOnly=true] - When true, returns only serializable
|
|
* tool definitions without creating full tool instances (for event-driven mode)
|
|
*/
|
|
function createToolLoader(signal, definitionsOnly = true) {
|
|
return async function loadTools({
|
|
req,
|
|
res,
|
|
tools,
|
|
model,
|
|
agentId,
|
|
provider,
|
|
tool_options,
|
|
tool_resources,
|
|
requestBody,
|
|
codeExecutionContext,
|
|
accessibleMcpServerNames,
|
|
}) {
|
|
const agent = { id: agentId, tools, provider, model, tool_options };
|
|
try {
|
|
return await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent,
|
|
signal,
|
|
requestBody,
|
|
tool_resources,
|
|
codeExecutionContext,
|
|
agentResourceType: ResourceType.REMOTE_AGENT,
|
|
definitionsOnly,
|
|
accessibleMcpServerNames,
|
|
streamId: null,
|
|
});
|
|
} catch (error) {
|
|
if (isFatalAgentInitializationError(error) || isContentFilterError(error)) {
|
|
throw error;
|
|
}
|
|
logger.error('Error loading tools for agent ' + agentId, getSafeErrorMetadata(error));
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert Open Responses input items to internal messages
|
|
* @param {import('@librechat/api').InputItem[]} input
|
|
* @returns {Array} Internal messages
|
|
*/
|
|
function convertToInternalMessages(input) {
|
|
return convertInputToMessages(input);
|
|
}
|
|
|
|
/**
|
|
* Collect file-derived context exactly as it will be exposed to the model.
|
|
* Dynamic tool context uses the same synthesis as packages/api/src/agents/run.ts.
|
|
* @param {Array} agents
|
|
* @returns {Array}
|
|
*/
|
|
function collectModelBoundAgentFiles(agents) {
|
|
const files = [];
|
|
const seenFiles = new Set();
|
|
for (const agent of agents) {
|
|
for (const attachment of [
|
|
...(agent?.attachments ?? []),
|
|
...(agent?.requestAttachments ?? []),
|
|
...(agent?.agentContextAttachments ?? []),
|
|
]) {
|
|
if (attachment == null || seenFiles.has(attachment)) {
|
|
continue;
|
|
}
|
|
seenFiles.add(attachment);
|
|
files.push(attachment);
|
|
}
|
|
|
|
const dynamicToolInstructions = Object.values(agent?.dynamicToolContextMap ?? {})
|
|
.filter((value) => typeof value === 'string' && value !== '')
|
|
.join('\n')
|
|
.trim();
|
|
if (dynamicToolInstructions !== '') {
|
|
files.push({ content: dynamicToolInstructions });
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function extractResponseRequestContent(request, messageFragments) {
|
|
const fragments = [
|
|
...extractAgentContent({ instructions: request.instructions }),
|
|
...messageFragments,
|
|
];
|
|
|
|
if (Array.isArray(request.input)) {
|
|
for (const item of request.input) {
|
|
if (item?.type !== 'message' || !Array.isArray(item.content)) {
|
|
continue;
|
|
}
|
|
for (const part of item.content) {
|
|
if (part?.type === 'input_file') {
|
|
fragments.push(...extractFileContent({ name: part.filename }));
|
|
continue;
|
|
}
|
|
if (
|
|
part?.type === 'input_image' &&
|
|
typeof part.image_url === 'string' &&
|
|
!part.image_url.startsWith('data:')
|
|
) {
|
|
fragments.push(...extractFileContent({ uri: part.image_url }));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const tool of request.tools ?? []) {
|
|
if (tool?.type !== 'function') {
|
|
continue;
|
|
}
|
|
fragments.push(
|
|
...extractAgentContent({
|
|
name: tool.name,
|
|
description: tool.description,
|
|
}),
|
|
);
|
|
try {
|
|
fragments.push(...extractToolArgumentContent({ arguments: tool.parameters }));
|
|
} catch (error) {
|
|
if (isContentTraversalLimitError(error)) {
|
|
prependContentTraversalFragments(error, fragments);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
try {
|
|
fragments.push(
|
|
...extractModelParameterContent({
|
|
metadata: request.metadata,
|
|
response_format: request.text?.format,
|
|
additionalModelRequestFields: {
|
|
user: request.user,
|
|
tool_choice: request.tool_choice,
|
|
reasoning: request.reasoning,
|
|
},
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
if (isContentTraversalLimitError(error)) {
|
|
prependContentTraversalFragments(error, fragments);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return fragments;
|
|
}
|
|
|
|
/**
|
|
* Load messages from a previous response/conversation
|
|
* @param {string} conversationId - The conversation/response ID
|
|
* @param {string} userId - The user ID
|
|
* @returns {Promise<Array>} Messages from the conversation
|
|
*/
|
|
async function loadPreviousMessages(conversationId, userId) {
|
|
try {
|
|
const messages = await db.getMessages({ conversationId, user: userId });
|
|
if (!messages || messages.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Convert stored messages to internal format
|
|
return messages.map((msg) => {
|
|
let text;
|
|
if (typeof msg.text === 'string') {
|
|
text = msg.text;
|
|
} else if (msg.text != null) {
|
|
text = String(msg.text);
|
|
}
|
|
const internalMsg = {
|
|
role: msg.isCreatedByUser ? 'user' : 'assistant',
|
|
content: Array.isArray(msg.content) ? msg.content : (text ?? ''),
|
|
messageId: msg.messageId,
|
|
isCreatedByUser: msg.isCreatedByUser === true,
|
|
...(text !== undefined && { text }),
|
|
...(typeof msg.isUserSubmitted === 'boolean' && {
|
|
isUserSubmitted: msg.isUserSubmitted,
|
|
}),
|
|
...(Array.isArray(msg.userSubmittedPaths) && {
|
|
userSubmittedPaths: msg.userSubmittedPaths,
|
|
}),
|
|
...(Array.isArray(msg.userSubmittedMessageFieldPaths) && {
|
|
userSubmittedMessageFieldPaths: msg.userSubmittedMessageFieldPaths,
|
|
}),
|
|
};
|
|
|
|
return internalMsg;
|
|
});
|
|
} catch (error) {
|
|
logger.error('[Responses API] Error loading previous messages:', getSafeErrorMetadata(error));
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save input messages to database
|
|
* @param {import('express').Request} req
|
|
* @param {string} conversationId
|
|
* @param {Array} inputMessages - Internal format messages
|
|
* @param {string} agentId
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function saveInputMessages(req, conversationId, inputMessages, agentId) {
|
|
for (const msg of inputMessages) {
|
|
if (msg.role === 'user') {
|
|
await db.saveMessage(
|
|
req,
|
|
{
|
|
messageId: msg.messageId || nanoid(),
|
|
conversationId,
|
|
parentMessageId: null,
|
|
isCreatedByUser: true,
|
|
text: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
sender: 'User',
|
|
endpoint: EModelEndpoint.agents,
|
|
model: agentId,
|
|
},
|
|
{ context: 'Responses API - save user input' },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save response output to database
|
|
* @param {import('express').Request} req
|
|
* @param {string} conversationId
|
|
* @param {string} responseId
|
|
* @param {import('@librechat/api').Response} response
|
|
* @param {string} agentId
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function saveResponseOutput(req, conversationId, responseId, response, agentId) {
|
|
// Extract text content from output items
|
|
let responseText = '';
|
|
for (const item of response.output) {
|
|
if (item.type === 'message' && item.content) {
|
|
for (const part of item.content) {
|
|
if (part.type === 'output_text' && part.text) {
|
|
responseText += part.text;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const langfuseTraceFields = await getLangfuseTraceMessageFields(req.config, responseId);
|
|
|
|
// Save the assistant message
|
|
await db.saveMessage(
|
|
req,
|
|
{
|
|
messageId: responseId,
|
|
conversationId,
|
|
parentMessageId: null,
|
|
isCreatedByUser: false,
|
|
...langfuseTraceFields,
|
|
text: responseText,
|
|
sender: 'Agent',
|
|
endpoint: EModelEndpoint.agents,
|
|
model: agentId,
|
|
finish_reason: response.status === 'completed' ? 'stop' : response.status,
|
|
tokenCount: response.usage?.output_tokens,
|
|
},
|
|
{ context: 'Responses API - save assistant response' },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Save or update conversation
|
|
* @param {import('express').Request} req
|
|
* @param {string} conversationId
|
|
* @param {string} agentId
|
|
* @param {object} agent
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function saveConversation(req, conversationId, agentId, agent) {
|
|
const title = resolveConversationTitle(req, agent?.name || 'Open Responses Conversation');
|
|
await db.saveConvo(
|
|
{
|
|
userId: req?.user?.id,
|
|
isTemporary: req?.body?.isTemporary,
|
|
interfaceConfig: req?.config?.interfaceConfig,
|
|
},
|
|
{
|
|
conversationId,
|
|
endpoint: EModelEndpoint.agents,
|
|
agentId,
|
|
...(title != null && { title }),
|
|
model: agent?.model,
|
|
},
|
|
{ context: 'Responses API - save conversation' },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Convert stored messages to Open Responses output format
|
|
* @param {Array} messages - Stored messages
|
|
* @returns {Array} Output items
|
|
*/
|
|
function convertMessagesToOutputItems(messages) {
|
|
const output = [];
|
|
|
|
for (const msg of messages) {
|
|
if (!msg.isCreatedByUser) {
|
|
output.push({
|
|
type: 'message',
|
|
id: msg.messageId,
|
|
role: 'assistant',
|
|
status: 'completed',
|
|
content: [
|
|
{
|
|
type: 'output_text',
|
|
text: msg.text || '',
|
|
annotations: [],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Runs a validated Responses envelope in the current process.
|
|
* Express remains runtime-only state while the envelope is the portable run input.
|
|
*
|
|
* @param {import('@librechat/api').ResponsesRunEnvelope} envelope
|
|
* @param {{req: import('express').Request, res: import('express').Response}} runtime
|
|
*/
|
|
const executeResponse = async (envelope, { req, res }) => {
|
|
const appConfig = req.config;
|
|
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 manualSkills = extractManualSkills(req.body);
|
|
const isStreaming = request.stream === true;
|
|
const summarizationConfig = appConfig?.summarization;
|
|
|
|
const uninspectableField = getBlockedOpaqueFileField(appConfig?.filters, request.input);
|
|
if (uninspectableField != null) {
|
|
const blockResponse = contentFilterUninspectableResponse(uninspectableField);
|
|
return sendResponsesErrorResponse(
|
|
res,
|
|
400,
|
|
blockResponse.message,
|
|
'invalid_request',
|
|
blockResponse.error,
|
|
);
|
|
}
|
|
|
|
const inputMessages = convertToInternalMessages(
|
|
typeof request.input === 'string' ? request.input : request.input,
|
|
);
|
|
const messageFragments = [];
|
|
const traversalErrors = [];
|
|
try {
|
|
for (const fragment of extractMessageContent(inputMessages)) {
|
|
messageFragments.push(fragment);
|
|
}
|
|
} catch (error) {
|
|
if (!isContentTraversalLimitError(error)) {
|
|
throw error;
|
|
}
|
|
messageFragments.push(...getContentTraversalFragments(error));
|
|
traversalErrors.push(error);
|
|
}
|
|
let requestFragments;
|
|
try {
|
|
requestFragments = extractResponseRequestContent(request, messageFragments);
|
|
} catch (error) {
|
|
if (!isContentTraversalLimitError(error)) {
|
|
throw error;
|
|
}
|
|
requestFragments = getContentTraversalFragments(error);
|
|
traversalErrors.push(error);
|
|
}
|
|
const contentFinding = inspectContent(
|
|
[...requestFragments, ...(manualSkills ?? []).flatMap((name) => extractSkillContent({ name }))],
|
|
{
|
|
filters: appConfig?.filters,
|
|
legacyPii: appConfig?.messageFilter?.pii,
|
|
},
|
|
);
|
|
if (contentFinding != null) {
|
|
const isLegacyFilter = contentFinding.detectorId === 'legacy-pattern';
|
|
const blockResponse = contentFilterBlockResponse(contentFinding);
|
|
return sendResponsesErrorResponse(
|
|
res,
|
|
400,
|
|
isLegacyFilter
|
|
? `Message contains a ${contentFinding.label}. Remove it and try again.`
|
|
: blockResponse.message,
|
|
'invalid_request',
|
|
isLegacyFilter ? 'message_filter_pii_block' : blockResponse.error,
|
|
);
|
|
}
|
|
const traversalError = traversalErrors.find((error) =>
|
|
isContentTraversalProtected({
|
|
error,
|
|
filters: appConfig?.filters,
|
|
legacyPii: appConfig?.messageFilter?.pii,
|
|
roles: inputMessages.map((message) => message?.role),
|
|
}),
|
|
);
|
|
if (traversalError != null) {
|
|
return sendResponsesErrorResponse(
|
|
res,
|
|
traversalError.statusCode,
|
|
traversalError.body.message,
|
|
'invalid_request',
|
|
traversalError.body.error,
|
|
);
|
|
}
|
|
|
|
// Look up the agent
|
|
const agent = await db.getAgent({ id: agentId });
|
|
if (!agent) {
|
|
return sendResponsesErrorResponse(
|
|
res,
|
|
404,
|
|
`Agent not found: ${agentId}`,
|
|
'not_found',
|
|
'model_not_found',
|
|
);
|
|
}
|
|
|
|
// Generate IDs
|
|
const responseId = generateResponseId();
|
|
const context = createResponseContext(request, responseId);
|
|
|
|
logger.debug(
|
|
`[Responses API] Request ${responseId} started for agent ${agentId}, stream: ${isStreaming}`,
|
|
);
|
|
|
|
// Set up abort controller
|
|
const abortController = new AbortController();
|
|
|
|
// Handle client disconnect
|
|
req.on('close', () => {
|
|
if (!abortController.signal.aborted) {
|
|
abortController.abort();
|
|
logger.debug('[Responses API] Client disconnected, aborting');
|
|
}
|
|
});
|
|
|
|
try {
|
|
if (request.previous_response_id != null) {
|
|
if (typeof request.previous_response_id !== 'string') {
|
|
return sendResponsesErrorResponse(
|
|
res,
|
|
400,
|
|
'previous_response_id must be a string',
|
|
'invalid_request',
|
|
);
|
|
}
|
|
const previousConversation = await db.getConvo(
|
|
principal.userId,
|
|
request.previous_response_id,
|
|
);
|
|
if (!previousConversation) {
|
|
return sendResponsesErrorResponse(res, 404, 'Conversation not found', 'not_found');
|
|
}
|
|
if (previousConversation.subagentThread != null) {
|
|
return sendResponsesErrorResponse(
|
|
res,
|
|
409,
|
|
CHILD_THREAD_READ_ONLY_ERROR,
|
|
'invalid_request',
|
|
'conversation_read_only',
|
|
);
|
|
}
|
|
}
|
|
|
|
const conversationId = request.previous_response_id ?? uuidv4();
|
|
const parentMessageId = null;
|
|
const mcpRequestBody = createMCPRuntimeRequestBody({ messageId: responseId, conversationId });
|
|
const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
|
|
const previousMessages = request.previous_response_id
|
|
? await loadPreviousMessages(request.previous_response_id, principal.userId)
|
|
: [];
|
|
if (request.previous_response_id) {
|
|
assertModelBoundContent({
|
|
filters: appConfig?.filters,
|
|
legacyPii: appConfig?.messageFilter?.pii,
|
|
storedMessages: previousMessages,
|
|
});
|
|
}
|
|
|
|
// Build allowed providers set
|
|
const allowedProviders = new Set(agentsEConfig?.allowedProviders);
|
|
|
|
// Create tool loader
|
|
const loadTools = createToolLoader(abortController.signal);
|
|
const skillDbMethods = getSkillDbMethods();
|
|
|
|
// Initialize the agent first to check for disableStreaming
|
|
const endpointOption = {
|
|
endpoint: agent.provider,
|
|
model_parameters: agent.model_parameters ?? {},
|
|
};
|
|
|
|
const dbMethods = {
|
|
getConvoFiles: db.getConvoFiles,
|
|
getFiles: db.getFiles,
|
|
filterFilesByAgentAccess: filterFilesByRemoteAgentAccess,
|
|
getUserKey: db.getUserKey,
|
|
getMessages: db.getMessages,
|
|
getAccessibleMcpServerNames,
|
|
updateFilesUsage: db.updateFilesUsage,
|
|
getUserKeyValues: db.getUserKeyValues,
|
|
getUserCodeFiles: db.getUserCodeFiles,
|
|
getToolFilesByIds: db.getToolFilesByIds,
|
|
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
|
|
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
|
|
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
|
|
getSkillByName: skillDbMethods.getSkillByName,
|
|
};
|
|
|
|
const enabledCapabilities = new Set(agentsEConfig?.capabilities);
|
|
const memoryAvailable = await resolveMemoryAvailability({
|
|
enabledCapabilities,
|
|
memoryConfig: appConfig?.memory,
|
|
user: req.user,
|
|
getRoleByName: db.getRoleByName,
|
|
});
|
|
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
|
|
const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true;
|
|
const accessibleSkillIds = skillsCapabilityEnabled
|
|
? withDeploymentSkillIds(
|
|
await findAccessibleResources({
|
|
userId: principal.userId,
|
|
role: principal.role,
|
|
resourceType: ResourceType.SKILL,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
}),
|
|
)
|
|
: [];
|
|
const editableSkillIds = skillsCapabilityEnabled
|
|
? await findAccessibleResources({
|
|
userId: principal.userId,
|
|
role: principal.role,
|
|
resourceType: ResourceType.SKILL,
|
|
requiredPermissions: PermissionBits.EDIT,
|
|
})
|
|
: [];
|
|
const skillCreateAllowed = skillsCapabilityEnabled
|
|
? await getSkillToolDeps().canCreateSkill({ req })
|
|
: false;
|
|
|
|
const { skillStates, defaultActiveOnShare } = await loadSkillStates({
|
|
userId: principal.userId,
|
|
appConfig,
|
|
getUserById: db.getUserById,
|
|
accessibleSkillIds,
|
|
});
|
|
|
|
const primaryScopedSkillIds = resolveAgentScopedSkillIds({
|
|
agent,
|
|
accessibleSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
});
|
|
const primaryScopedEditableSkillIds = resolveAgentScopedSkillIds({
|
|
agent,
|
|
accessibleSkillIds: editableSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
});
|
|
|
|
const primaryConfig = await initializeAgent(
|
|
{
|
|
req,
|
|
res,
|
|
loadTools,
|
|
requestFiles: [],
|
|
conversationId,
|
|
parentMessageId,
|
|
requestBody: mcpRequestBody,
|
|
agent,
|
|
endpointOption,
|
|
allowedProviders,
|
|
isInitialAgent: true,
|
|
accessibleSkillIds: primaryScopedSkillIds,
|
|
skillAuthoringAvailable: canAuthorSkillFiles({
|
|
agent,
|
|
scopedEditableSkillIds: primaryScopedEditableSkillIds,
|
|
skillCreateAllowed,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
}),
|
|
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
|
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
|
|
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
|
|
statefulSessionsAvailable: enabledCapabilities.has(
|
|
AgentCapabilities.stateful_code_sessions,
|
|
),
|
|
allowedStatefulCodeEnvironments: agentsEConfig?.statefulCodeSessions?.allowedEnvironments,
|
|
memoryAvailable,
|
|
skillStates,
|
|
defaultActiveOnShare,
|
|
manualSkills,
|
|
},
|
|
dbMethods,
|
|
);
|
|
|
|
/**
|
|
* Per-agent tool-execution context map, keyed by agentId. Ensures the
|
|
* ON_TOOL_EXECUTE callback routes each sub-agent's tool calls to the
|
|
* correct toolRegistry / userMCPAuthMap / tool_resources.
|
|
* @type {Map<string, {
|
|
* agent: object,
|
|
* toolRegistry?: import('@librechat/agents').LCToolRegistry,
|
|
* requestScopedConnections?: import('@librechat/api').RequestScopedMCPConnectionStore,
|
|
* userMCPAuthMap?: Record<string, Record<string, string>>,
|
|
* tool_resources?: object,
|
|
* actionsEnabled?: boolean,
|
|
* }>}
|
|
*/
|
|
const agentToolContexts = new Map();
|
|
agentToolContexts.set(
|
|
primaryConfig.id,
|
|
buildAgentToolContext({ agent, config: primaryConfig }),
|
|
);
|
|
|
|
let handoffAgentConfigs = new Map();
|
|
let discoveredEdges = [];
|
|
let discoveredMCPAuthMap;
|
|
const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents);
|
|
const primaryHasGraphSubagents =
|
|
subagentsCapabilityEnabled &&
|
|
primaryConfig.subagents?.enabled === true &&
|
|
(primaryConfig.subagents.graphs?.length ?? 0) > 0;
|
|
if (primaryConfig.edges?.length || primaryHasGraphSubagents) {
|
|
const modelsConfig = await getModelsConfig(req);
|
|
const discoveryParams = {
|
|
req,
|
|
res,
|
|
primaryConfig,
|
|
endpointOption,
|
|
allowedProviders,
|
|
modelsConfig,
|
|
loadTools,
|
|
requestFiles: [],
|
|
conversationId,
|
|
parentMessageId,
|
|
requestBody: mcpRequestBody,
|
|
resourceType: ResourceType.REMOTE_AGENT,
|
|
computeAccessibleSkillIds: (handoffAgent) =>
|
|
resolveAgentScopedSkillIds({
|
|
agent: handoffAgent,
|
|
accessibleSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
}),
|
|
computeSkillAuthoringAvailable: (handoffAgent) =>
|
|
canAuthorSkillFiles({
|
|
agent: handoffAgent,
|
|
scopedEditableSkillIds: resolveAgentScopedSkillIds({
|
|
agent: handoffAgent,
|
|
accessibleSkillIds: editableSkillIds,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
}),
|
|
skillCreateAllowed,
|
|
skillsCapabilityEnabled,
|
|
ephemeralSkillsToggle,
|
|
}),
|
|
skillStates,
|
|
defaultActiveOnShare,
|
|
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
|
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
|
|
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
|
|
statefulSessionsAvailable: enabledCapabilities.has(
|
|
AgentCapabilities.stateful_code_sessions,
|
|
),
|
|
allowedStatefulCodeEnvironments: agentsEConfig?.statefulCodeSessions?.allowedEnvironments,
|
|
memoryAvailable,
|
|
};
|
|
const discoveryDeps = {
|
|
getAgent: db.getAgent,
|
|
checkPermission: async ({ userId, role, resourceId, requiredPermission }) => {
|
|
const permissions = await getRemoteAgentPermissions(
|
|
{ getEffectivePermissions },
|
|
userId,
|
|
role,
|
|
resourceId,
|
|
);
|
|
return hasPermissions(permissions, requiredPermission);
|
|
},
|
|
logViolation,
|
|
db: dbMethods,
|
|
onAgentInitialized: (loadedAgentId, loadedAgent, config) => {
|
|
agentToolContexts.set(
|
|
loadedAgentId,
|
|
buildAgentToolContext({ agent: loadedAgent, config }),
|
|
);
|
|
},
|
|
initializeAgent,
|
|
};
|
|
if (primaryConfig.edges?.length) {
|
|
({
|
|
agentConfigs: handoffAgentConfigs,
|
|
edges: discoveredEdges,
|
|
userMCPAuthMap: discoveredMCPAuthMap,
|
|
} = await discoverConnectedAgents(discoveryParams, discoveryDeps));
|
|
}
|
|
if (subagentsCapabilityEnabled) {
|
|
discoveredMCPAuthMap = await resolveSubagentGraphs(
|
|
{
|
|
...discoveryParams,
|
|
rootConfigs: [primaryConfig, ...handoffAgentConfigs.values()],
|
|
},
|
|
discoveryDeps,
|
|
);
|
|
}
|
|
}
|
|
|
|
primaryConfig.edges = discoveredEdges;
|
|
const endpointTokenConfigByAgentId = new Map();
|
|
for (const [agentId, context] of agentToolContexts) {
|
|
endpointTokenConfigByAgentId.set(agentId, context.endpointTokenConfig);
|
|
}
|
|
const resolveEndpointTokenConfig = (usage) =>
|
|
resolveAgentTokenConfig({
|
|
agentId: usage?.agentId,
|
|
byAgentId: endpointTokenConfigByAgentId,
|
|
fallback: primaryConfig.endpointTokenConfig,
|
|
});
|
|
const runAgents = [primaryConfig, ...handoffAgentConfigs.values()];
|
|
const initialSessions = buildInitialToolSessions({ agents: runAgents });
|
|
const modelBoundAgentsById = new Map();
|
|
const pendingModelBoundAgents = [...runAgents];
|
|
for (let index = 0; index < pendingModelBoundAgents.length; index++) {
|
|
const runAgent = pendingModelBoundAgents[index];
|
|
if (!runAgent?.id || modelBoundAgentsById.has(runAgent.id)) {
|
|
continue;
|
|
}
|
|
modelBoundAgentsById.set(runAgent.id, runAgent);
|
|
for (const subagent of runAgent.subagentAgentConfigs?.values?.() ?? []) {
|
|
pendingModelBoundAgents.push(subagent);
|
|
}
|
|
for (const graph of runAgent.subagentGraphConfigs ?? []) {
|
|
pendingModelBoundAgents.push(...graph.memberConfigs);
|
|
}
|
|
}
|
|
const modelBoundAgents = [...modelBoundAgentsById.values()];
|
|
const mergedMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap;
|
|
assertModelBoundContent({
|
|
filters: appConfig?.filters,
|
|
legacyPii: appConfig?.messageFilter?.pii,
|
|
agents: modelBoundAgents,
|
|
});
|
|
|
|
const agentContextAttachmentsByAgentId =
|
|
buildAgentContextAttachmentsByAgentId(modelBoundAgents);
|
|
const agentScopedContext = await buildAgentScopedContext({
|
|
agentIds: modelBoundAgents.map(({ id }) => id),
|
|
attachmentsByAgentId: agentContextAttachmentsByAgentId,
|
|
req,
|
|
});
|
|
|
|
const mcpManager = getMCPManager();
|
|
const configServers = await resolveConfigServers(req);
|
|
|
|
await Promise.all(
|
|
modelBoundAgents.map(async (runAgent) => {
|
|
const memoryContext = await buildInlineMemoryContext({
|
|
agent: runAgent,
|
|
req,
|
|
userId: principal.userId,
|
|
memoryAvailable,
|
|
getFormattedMemories: db.getFormattedMemories,
|
|
});
|
|
return applyContextToAgent({
|
|
agent: runAgent,
|
|
agentId: runAgent.id,
|
|
logger,
|
|
mcpManager,
|
|
configServers,
|
|
sharedRunContext: [memoryContext, agentScopedContext.get(runAgent.id)]
|
|
.filter(Boolean)
|
|
.join('\n\n'),
|
|
});
|
|
}),
|
|
);
|
|
|
|
// Determine if streaming is enabled (check both request and agent config)
|
|
const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming;
|
|
const actuallyStreaming = isStreaming && !streamingDisabled;
|
|
|
|
// Merge previous messages with new input
|
|
const allMessages = [...previousMessages, ...inputMessages];
|
|
|
|
const toolSet = buildToolSet(primaryConfig);
|
|
const formatted = formatAgentMessages(stripActivityLabelParts(allMessages), {}, toolSet);
|
|
const formattedMessages = formatted.messages;
|
|
const initialSummary = formatted.summary;
|
|
let indexTokenCountMap = formatted.indexTokenCountMap;
|
|
|
|
/**
|
|
* Inject manual + always-apply skill primes so the model sees SKILL.md
|
|
* bodies for this turn — parity with AgentClient's chat path. The
|
|
* Responses API uses its own response-builder shape, so LibreChat-
|
|
* style card SSE events don't apply; only the message-context part
|
|
* carries over.
|
|
*/
|
|
const manualSkillPrimes = primaryConfig.manualSkillPrimes;
|
|
const alwaysApplySkillPrimes = primaryConfig.alwaysApplySkillPrimes;
|
|
if (
|
|
(manualSkillPrimes && manualSkillPrimes.length > 0) ||
|
|
(alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0)
|
|
) {
|
|
const primeResult = injectSkillPrimes({
|
|
initialMessages: formattedMessages,
|
|
indexTokenCountMap,
|
|
manualSkillPrimes,
|
|
alwaysApplySkillPrimes,
|
|
});
|
|
indexTokenCountMap = primeResult.indexTokenCountMap;
|
|
/* Surface the cap-driven always-apply truncation at the controller
|
|
layer too — `injectSkillPrimes` already logs internally, but the
|
|
controller-level warn includes endpoint context so operators can
|
|
tell at a glance which path hit the cap. Mirrors AgentClient's
|
|
warn in `client.js`. */
|
|
if (primeResult.alwaysApplyDropped > 0) {
|
|
logger.warn(
|
|
`[Responses API] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
assertModelBoundContent({
|
|
filters: appConfig?.filters,
|
|
legacyPii: appConfig?.messageFilter?.pii,
|
|
submittedMessages: inputMessages,
|
|
agents: modelBoundAgents,
|
|
skills: [...(manualSkillPrimes ?? []), ...(alwaysApplySkillPrimes ?? [])],
|
|
files: collectModelBoundAgentFiles(modelBoundAgents),
|
|
});
|
|
|
|
/* Stable for the turn: the primary prime list is fixed once
|
|
`initializeAgent` resolves and is used as the fallback when a
|
|
specific agent context is unavailable. `codeEnvAvailable` is read
|
|
per-agent from the stored tool context (admin cap AND that
|
|
agent's `tools` list includes `execute_code`) — a skills-only
|
|
agent never gains sandbox access even if the admin enabled the
|
|
capability globally. */
|
|
// Create tracker for streaming or aggregator for non-streaming
|
|
const tracker = actuallyStreaming ? createResponseTracker() : null;
|
|
const aggregator = actuallyStreaming ? null : createResponseAggregator();
|
|
|
|
// Set up response for streaming
|
|
if (actuallyStreaming) {
|
|
setupStreamingResponse(res);
|
|
|
|
// Create handler config
|
|
const handlerConfig = {
|
|
res,
|
|
context,
|
|
tracker,
|
|
};
|
|
|
|
// Emit response.created then response.in_progress per Open Responses spec
|
|
emitResponseCreated(handlerConfig);
|
|
emitResponseInProgress(handlerConfig);
|
|
|
|
// Create event handlers
|
|
const { handlers: responsesHandlers, finalizeStream } =
|
|
createResponsesEventHandlers(handlerConfig);
|
|
|
|
// Collect usage for balance tracking
|
|
const collectedUsage = [];
|
|
|
|
// Artifact promises for processing tool outputs
|
|
/** @type {Promise<import('librechat-data-provider').TAttachment | null>[]} */
|
|
const artifactPromises = [];
|
|
// Use Responses API-specific callback that emits librechat:attachment events
|
|
const toolEndCallback = createResponsesToolEndCallback({
|
|
req,
|
|
res,
|
|
tracker,
|
|
artifactPromises,
|
|
});
|
|
|
|
// Create tool execute options for event-driven tool execution
|
|
const toolExecuteOptions = {
|
|
loadTools: async (toolNames, agentId) => {
|
|
const ctx =
|
|
agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res,
|
|
agentResourceType: ResourceType.REMOTE_AGENT,
|
|
conversationId,
|
|
requestBody: mcpRequestBody,
|
|
toolNames,
|
|
agent: ctx.agent ?? agent,
|
|
signal: abortController.signal,
|
|
toolRegistry: ctx.toolRegistry,
|
|
backgroundToolNames: ctx.backgroundToolNames,
|
|
intentToolNames: ctx.intentToolNames,
|
|
mcpAvailableTools: ctx.mcpAvailableTools,
|
|
requestScopedConnections: ctx.requestScopedConnections,
|
|
userMCPAuthMap: ctx.userMCPAuthMap,
|
|
tool_resources: ctx.tool_resources,
|
|
actionsEnabled: ctx.actionsEnabled,
|
|
accessibleMcpServerNames: ctx.accessibleMcpServerNames,
|
|
});
|
|
return enrichLoadedToolsWithAgentContext({
|
|
result,
|
|
req,
|
|
ctx,
|
|
});
|
|
},
|
|
toolEndCallback,
|
|
...getSkillToolDeps(),
|
|
};
|
|
|
|
// Combine handlers
|
|
const handlers = {
|
|
on_message_delta: responsesHandlers.on_message_delta,
|
|
on_reasoning_delta: responsesHandlers.on_reasoning_delta,
|
|
on_run_step: responsesHandlers.on_run_step,
|
|
on_run_step_delta: responsesHandlers.on_run_step_delta,
|
|
on_chat_model_end: {
|
|
handle: (event, data, metadata) => {
|
|
responsesHandlers.on_chat_model_end.handle(event, data);
|
|
const usage = data?.output?.usage_metadata;
|
|
if (usage) {
|
|
const taggedUsage = markSummarizationUsage(usage, metadata);
|
|
collectedUsage.push(taggedUsage);
|
|
}
|
|
},
|
|
},
|
|
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
|
|
on_run_step_completed: { handle: () => {} },
|
|
on_chain_stream: { handle: () => {} },
|
|
on_chain_end: { handle: () => {} },
|
|
on_agent_update: { handle: () => {} },
|
|
on_custom_event: { handle: () => {} },
|
|
on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
|
|
on_agent_log: agentLogHandlerObj,
|
|
...(summarizationConfig?.enabled !== false
|
|
? buildSummarizationHandlers({ isStreaming: actuallyStreaming, res })
|
|
: {}),
|
|
};
|
|
|
|
// Create and run the agent
|
|
const userId = principal.userId;
|
|
const userMCPAuthMap = mergedMCPAuthMap;
|
|
|
|
const run = await createRun({
|
|
agents: runAgents,
|
|
messages: formattedMessages,
|
|
indexTokenCountMap,
|
|
initialSummary,
|
|
runId: responseId,
|
|
summarizationConfig,
|
|
appConfig,
|
|
signal: abortController.signal,
|
|
customHandlers: handlers,
|
|
initialSessions,
|
|
requestBody: mcpRequestBody,
|
|
user: { id: userId },
|
|
tenantId: principal.tenantId,
|
|
/** Bills subagent child-run model calls (reported outside the
|
|
* streamEvents loop) into the same collectedUsage array. */
|
|
subagentUsageSink: createSubagentUsageSink(collectedUsage, (usage) => {
|
|
responsesHandlers.on_chat_model_end.handle('on_chat_model_end', {
|
|
output: { usage_metadata: usage },
|
|
});
|
|
}),
|
|
});
|
|
|
|
if (!run) {
|
|
throw new Error('Failed to create agent run');
|
|
}
|
|
|
|
// Process the stream
|
|
const config = {
|
|
runName: 'AgentRun',
|
|
configurable: {
|
|
thread_id: conversationId,
|
|
user_id: userId,
|
|
user: createSafeUser(req.user),
|
|
requestBody: mcpRequestBody,
|
|
...(userMCPAuthMap != null && { userMCPAuthMap }),
|
|
},
|
|
signal: abortController.signal,
|
|
streamMode: 'values',
|
|
version: 'v2',
|
|
};
|
|
|
|
await run.processStream({ messages: formattedMessages }, config, {
|
|
callbacks: {
|
|
[Callback.TOOL_ERROR]: (graph, error, toolId) => {
|
|
logger.error(`[Responses API] Tool Error "${toolId}"`, getSafeErrorMetadata(error));
|
|
},
|
|
},
|
|
});
|
|
|
|
// Record token usage against balance
|
|
const balanceConfig = getBalanceConfig(appConfig);
|
|
const transactionsConfig = getTransactionsConfig(appConfig);
|
|
recordCollectedUsage(
|
|
{
|
|
spendTokens: db.spendTokens,
|
|
spendStructuredTokens: db.spendStructuredTokens,
|
|
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
|
|
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
|
|
},
|
|
{
|
|
user: userId,
|
|
conversationId,
|
|
collectedUsage,
|
|
context: 'message',
|
|
messageId: responseId,
|
|
balance: balanceConfig,
|
|
transactions: transactionsConfig,
|
|
model: primaryConfig.model || agent.model_parameters?.model,
|
|
endpointTokenConfig: primaryConfig.endpointTokenConfig,
|
|
resolveEndpointTokenConfig,
|
|
},
|
|
).catch((err) => {
|
|
logger.error('[Responses API] Error recording usage:', getSafeErrorMetadata(err));
|
|
});
|
|
|
|
// Finalize the stream
|
|
finalizeStream();
|
|
res.end();
|
|
|
|
const duration = Date.now() - requestStartTime;
|
|
logger.debug(`[Responses API] Request ${responseId} completed in ${duration}ms (streaming)`);
|
|
|
|
// Save to database if store: true
|
|
if (request.store === true) {
|
|
try {
|
|
// Save conversation
|
|
await saveConversation(req, conversationId, agentId, agent);
|
|
|
|
// Save input messages
|
|
await saveInputMessages(req, conversationId, inputMessages, agentId);
|
|
|
|
// Build response for saving (use tracker with buildResponse for streaming)
|
|
const finalResponse = buildResponse(context, tracker, 'completed');
|
|
await saveResponseOutput(req, conversationId, responseId, finalResponse, agentId);
|
|
|
|
logger.debug(
|
|
`[Responses API] Stored response ${responseId} in conversation ${conversationId}`,
|
|
);
|
|
} catch (saveError) {
|
|
logger.error('[Responses API] Error saving response:', getSafeErrorMetadata(saveError));
|
|
// Don't fail the request if saving fails
|
|
}
|
|
}
|
|
|
|
// Wait for artifact processing after response ends (non-blocking)
|
|
if (artifactPromises.length > 0) {
|
|
Promise.all(artifactPromises).catch((artifactError) => {
|
|
logger.warn(
|
|
'[Responses API] Error processing artifacts:',
|
|
getSafeErrorMetadata(artifactError),
|
|
);
|
|
});
|
|
}
|
|
} else {
|
|
const aggregatorHandlers = createAggregatorEventHandlers(aggregator);
|
|
|
|
// Collect usage for balance tracking
|
|
const collectedUsage = [];
|
|
|
|
/** @type {Promise<import('librechat-data-provider').TAttachment | null>[]} */
|
|
const artifactPromises = [];
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
|
|
|
|
const toolExecuteOptions = {
|
|
loadTools: async (toolNames, agentId) => {
|
|
const ctx =
|
|
agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
|
|
const result = await loadToolsForExecution({
|
|
req,
|
|
res,
|
|
agentResourceType: ResourceType.REMOTE_AGENT,
|
|
conversationId,
|
|
requestBody: mcpRequestBody,
|
|
toolNames,
|
|
agent: ctx.agent ?? agent,
|
|
signal: abortController.signal,
|
|
toolRegistry: ctx.toolRegistry,
|
|
backgroundToolNames: ctx.backgroundToolNames,
|
|
intentToolNames: ctx.intentToolNames,
|
|
mcpAvailableTools: ctx.mcpAvailableTools,
|
|
requestScopedConnections: ctx.requestScopedConnections,
|
|
userMCPAuthMap: ctx.userMCPAuthMap,
|
|
tool_resources: ctx.tool_resources,
|
|
actionsEnabled: ctx.actionsEnabled,
|
|
accessibleMcpServerNames: ctx.accessibleMcpServerNames,
|
|
});
|
|
return enrichLoadedToolsWithAgentContext({
|
|
result,
|
|
req,
|
|
ctx,
|
|
});
|
|
},
|
|
toolEndCallback,
|
|
...getSkillToolDeps(),
|
|
};
|
|
|
|
const handlers = {
|
|
on_message_delta: aggregatorHandlers.on_message_delta,
|
|
on_reasoning_delta: aggregatorHandlers.on_reasoning_delta,
|
|
on_run_step: aggregatorHandlers.on_run_step,
|
|
on_run_step_delta: aggregatorHandlers.on_run_step_delta,
|
|
on_chat_model_end: {
|
|
handle: (event, data, metadata) => {
|
|
aggregatorHandlers.on_chat_model_end.handle(event, data);
|
|
const usage = data?.output?.usage_metadata;
|
|
if (usage) {
|
|
const taggedUsage = markSummarizationUsage(usage, metadata);
|
|
collectedUsage.push(taggedUsage);
|
|
}
|
|
},
|
|
},
|
|
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
|
|
on_run_step_completed: { handle: () => {} },
|
|
on_chain_stream: { handle: () => {} },
|
|
on_chain_end: { handle: () => {} },
|
|
on_agent_update: { handle: () => {} },
|
|
on_custom_event: { handle: () => {} },
|
|
on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
|
|
on_agent_log: agentLogHandlerObj,
|
|
...(summarizationConfig?.enabled !== false
|
|
? buildSummarizationHandlers({ isStreaming: false, res })
|
|
: {}),
|
|
};
|
|
|
|
const userId = principal.userId;
|
|
const userMCPAuthMap = mergedMCPAuthMap;
|
|
|
|
const run = await createRun({
|
|
agents: runAgents,
|
|
messages: formattedMessages,
|
|
indexTokenCountMap,
|
|
initialSummary,
|
|
runId: responseId,
|
|
summarizationConfig,
|
|
appConfig,
|
|
signal: abortController.signal,
|
|
customHandlers: handlers,
|
|
initialSessions,
|
|
requestBody: mcpRequestBody,
|
|
user: { id: userId },
|
|
tenantId: principal.tenantId,
|
|
/** Bills subagent child-run model calls (reported outside the
|
|
* streamEvents loop) into the same collectedUsage array. */
|
|
subagentUsageSink: createSubagentUsageSink(collectedUsage, (usage) => {
|
|
aggregatorHandlers.on_chat_model_end.handle('on_chat_model_end', {
|
|
output: { usage_metadata: usage },
|
|
});
|
|
}),
|
|
});
|
|
|
|
if (!run) {
|
|
throw new Error('Failed to create agent run');
|
|
}
|
|
|
|
const config = {
|
|
runName: 'AgentRun',
|
|
configurable: {
|
|
thread_id: conversationId,
|
|
user_id: userId,
|
|
user: createSafeUser(req.user),
|
|
requestBody: mcpRequestBody,
|
|
...(userMCPAuthMap != null && { userMCPAuthMap }),
|
|
},
|
|
signal: abortController.signal,
|
|
streamMode: 'values',
|
|
version: 'v2',
|
|
};
|
|
|
|
await run.processStream({ messages: formattedMessages }, config, {
|
|
callbacks: {
|
|
[Callback.TOOL_ERROR]: (graph, error, toolId) => {
|
|
logger.error(`[Responses API] Tool Error "${toolId}"`, getSafeErrorMetadata(error));
|
|
},
|
|
},
|
|
});
|
|
|
|
// Record token usage against balance
|
|
const balanceConfig = getBalanceConfig(appConfig);
|
|
const transactionsConfig = getTransactionsConfig(appConfig);
|
|
recordCollectedUsage(
|
|
{
|
|
spendTokens: db.spendTokens,
|
|
spendStructuredTokens: db.spendStructuredTokens,
|
|
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
|
|
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
|
|
},
|
|
{
|
|
user: userId,
|
|
conversationId,
|
|
collectedUsage,
|
|
context: 'message',
|
|
messageId: responseId,
|
|
balance: balanceConfig,
|
|
transactions: transactionsConfig,
|
|
model: primaryConfig.model || agent.model_parameters?.model,
|
|
endpointTokenConfig: primaryConfig.endpointTokenConfig,
|
|
resolveEndpointTokenConfig,
|
|
},
|
|
).catch((err) => {
|
|
logger.error('[Responses API] Error recording usage:', getSafeErrorMetadata(err));
|
|
});
|
|
|
|
if (artifactPromises.length > 0) {
|
|
try {
|
|
await Promise.all(artifactPromises);
|
|
} catch (artifactError) {
|
|
logger.warn(
|
|
'[Responses API] Error processing artifacts:',
|
|
getSafeErrorMetadata(artifactError),
|
|
);
|
|
}
|
|
}
|
|
|
|
const response = buildAggregatedResponse(context, aggregator);
|
|
|
|
if (request.store === true) {
|
|
try {
|
|
await saveConversation(req, conversationId, agentId, agent);
|
|
|
|
await saveInputMessages(req, conversationId, inputMessages, agentId);
|
|
|
|
await saveResponseOutput(req, conversationId, responseId, response, agentId);
|
|
|
|
logger.debug(
|
|
`[Responses API] Stored response ${responseId} in conversation ${conversationId}`,
|
|
);
|
|
} catch (saveError) {
|
|
logger.error('[Responses API] Error saving response:', getSafeErrorMetadata(saveError));
|
|
// Don't fail the request if saving fails
|
|
}
|
|
}
|
|
|
|
res.json(response);
|
|
|
|
const duration = Date.now() - requestStartTime;
|
|
logger.debug(
|
|
`[Responses API] Request ${responseId} completed in ${duration}ms (non-streaming)`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[Responses API] Error:', getSafeErrorMetadata(error));
|
|
const protectionEnabled = hasModelBoundContentProtection(
|
|
appConfig?.filters,
|
|
appConfig?.messageFilter?.pii,
|
|
);
|
|
const errorMessage = getUserFacingProviderError(error, protectionEnabled);
|
|
|
|
// Check if we already started streaming (headers sent)
|
|
if (res.headersSent) {
|
|
// Headers already sent, write error event and close
|
|
writeDone(res);
|
|
res.end();
|
|
} else {
|
|
if (isContentFilterError(error)) {
|
|
return sendResponsesErrorResponse(
|
|
res,
|
|
error.statusCode,
|
|
error.body.message,
|
|
'invalid_request',
|
|
error.body.error,
|
|
);
|
|
}
|
|
// Forward upstream provider status codes (e.g., Anthropic 400s) instead of masking as 500
|
|
const statusCode =
|
|
typeof error?.status === 'number' && error.status >= 400 && error.status < 600
|
|
? error.status
|
|
: 500;
|
|
const errorType = statusCode >= 400 && statusCode < 500 ? 'invalid_request' : 'server_error';
|
|
const errorCode =
|
|
!protectionEnabled && typeof error?.code === 'string' ? error.code : undefined;
|
|
if (errorCode === undefined) {
|
|
sendResponsesErrorResponse(res, statusCode, errorMessage, errorType);
|
|
} else {
|
|
sendResponsesErrorResponse(res, statusCode, errorMessage, errorType, errorCode);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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)
|
|
*
|
|
* Returns a list of available agents the user has remote access to.
|
|
*
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
*/
|
|
const listModels = async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
const userRole = req.user?.role;
|
|
|
|
if (!userId) {
|
|
return sendResponsesErrorResponse(res, 401, 'Authentication required', 'auth_error');
|
|
}
|
|
|
|
// Find agents the user has remote access to (VIEW permission on REMOTE_AGENT)
|
|
const accessibleAgentIds = await findAccessibleResources({
|
|
userId,
|
|
role: userRole,
|
|
resourceType: ResourceType.REMOTE_AGENT,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
});
|
|
|
|
// Get the accessible agents
|
|
let agents = [];
|
|
if (accessibleAgentIds.length > 0) {
|
|
agents = await db.getAgents({ _id: { $in: accessibleAgentIds } });
|
|
}
|
|
|
|
// Convert to models format
|
|
const models = agents.map((agent) => ({
|
|
id: agent.id,
|
|
object: 'model',
|
|
created: Math.floor(new Date(agent.createdAt).getTime() / 1000),
|
|
owned_by: agent.author ?? 'librechat',
|
|
// Additional metadata
|
|
name: agent.name,
|
|
description: agent.description,
|
|
provider: agent.provider,
|
|
}));
|
|
|
|
res.json({
|
|
object: 'list',
|
|
data: models,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[Responses API] Error listing models:', getSafeErrorMetadata(error));
|
|
sendResponsesErrorResponse(
|
|
res,
|
|
500,
|
|
error instanceof Error ? error.message : 'Failed to list models',
|
|
'server_error',
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get Response - GET /v1/responses/:id
|
|
*
|
|
* Retrieves a stored response by its ID.
|
|
* The response ID maps to a conversationId in LibreChat's storage.
|
|
*
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
*/
|
|
const getResponse = async (req, res) => {
|
|
try {
|
|
const responseId = req.params.id;
|
|
const userId = req.user?.id;
|
|
|
|
if (!responseId) {
|
|
return sendResponsesErrorResponse(res, 400, 'Response ID is required');
|
|
}
|
|
|
|
// The responseId could be either the response ID or the conversation ID
|
|
// Try to find a conversation with this ID
|
|
const conversation = await db.getConvo(userId, responseId);
|
|
|
|
if (!conversation) {
|
|
return sendResponsesErrorResponse(
|
|
res,
|
|
404,
|
|
`Response not found: ${responseId}`,
|
|
'not_found',
|
|
'response_not_found',
|
|
);
|
|
}
|
|
|
|
// Load messages for this conversation
|
|
const messages = await db.getMessages({ conversationId: responseId, user: userId });
|
|
|
|
if (!messages || messages.length === 0) {
|
|
return sendResponsesErrorResponse(
|
|
res,
|
|
404,
|
|
`No messages found for response: ${responseId}`,
|
|
'not_found',
|
|
'response_not_found',
|
|
);
|
|
}
|
|
|
|
// Convert messages to Open Responses output format
|
|
const output = convertMessagesToOutputItems(messages);
|
|
|
|
// Find the last assistant message for usage info
|
|
const lastAssistantMessage = messages.filter((m) => !m.isCreatedByUser).pop();
|
|
|
|
// Build the response object
|
|
const response = {
|
|
id: responseId,
|
|
object: 'response',
|
|
created_at: Math.floor(new Date(conversation.createdAt || Date.now()).getTime() / 1000),
|
|
completed_at: Math.floor(new Date(conversation.updatedAt || Date.now()).getTime() / 1000),
|
|
status: 'completed',
|
|
incomplete_details: null,
|
|
model: conversation.agentId || conversation.model || 'unknown',
|
|
previous_response_id: null,
|
|
instructions: null,
|
|
output,
|
|
error: null,
|
|
tools: [],
|
|
tool_choice: 'auto',
|
|
truncation: 'disabled',
|
|
parallel_tool_calls: true,
|
|
text: { format: { type: 'text' } },
|
|
temperature: 1,
|
|
top_p: 1,
|
|
presence_penalty: 0,
|
|
frequency_penalty: 0,
|
|
top_logprobs: null,
|
|
reasoning: null,
|
|
user: userId,
|
|
usage: lastAssistantMessage?.tokenCount
|
|
? {
|
|
input_tokens: 0,
|
|
output_tokens: lastAssistantMessage.tokenCount,
|
|
total_tokens: lastAssistantMessage.tokenCount,
|
|
}
|
|
: null,
|
|
max_output_tokens: null,
|
|
max_tool_calls: null,
|
|
store: true,
|
|
background: false,
|
|
service_tier: 'default',
|
|
metadata: {},
|
|
safety_identifier: null,
|
|
prompt_cache_key: null,
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
logger.error('[Responses API] Error getting response:', getSafeErrorMetadata(error));
|
|
sendResponsesErrorResponse(
|
|
res,
|
|
500,
|
|
error instanceof Error ? error.message : 'Failed to get response',
|
|
'server_error',
|
|
);
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
createResponse,
|
|
getResponse,
|
|
listModels,
|
|
};
|