mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 16:53:45 +00:00
* feat: per-agent skill selection in builder and runtime scoping
Wire skills persistence on the Agent model and enable the skills
section in the agents builder panel. At runtime, scope the skill
catalog to only the skills configured on each agent (intersected
with user ACL). When no skills are configured, the full user catalog
is used as the default. The ephemeral chat toggle overrides per-agent
scoping to provide the full catalog.
* fix: add scopeSkillIds to @librechat/api mock in responses unit test
The test mocks @librechat/api but was missing the newly imported
scopeSkillIds, causing createResponse to throw before reaching the
assertions. Added a passthrough mock that returns the input array.
* fix: scope primeInvokedSkills by agent's configured skills
primeInvokedSkills was receiving the full unscoped accessibleSkillIds,
bypassing the per-agent skill scoping applied to initializeAgent. This
allowed previously invoked skills from message history to be resolved
and primed even when excluded from the agent's configured skill set.
Apply the same scopeSkillIds filtering to match the initializeAgent
calls, so skill resolution is consistent across catalog injection
and history priming.
* fix: preserve agent skills through form reset and union prime scope
Two related bugs in the per-agent skill selection flow:
1. resetAgentForm dropped the persisted skills array because the generic
fall-through at the end of the loop excludes object/array values.
Combined with composeAgentUpdatePayload always emitting skills, this
caused any save of a previously-configured agent to silently overwrite
skills with an empty array. Add an explicit case for skills mirroring
the agent_ids handling.
2. primeInvokedSkills processes the full conversation payload, including
prior handoff-agent invocations. Scoping it to only primaryAgent.skills
meant a skill invoked by a handoff agent in a prior turn could not be
resolved when the current primary agent had a different scope, leaving
message history reconstruction incomplete. Union the per-agent scoped
accessibleSkillIds across primary plus all loaded handoff agents so
any skill any active agent could invoke is resolvable from history.
* fix: mark inline skill removals as dirty
The inline X button on the skills list called setValue without
shouldDirty: true, so removing a skill via this control did not
mark the skills field as dirty in react-hook-form state. When a
user removed a skill with the X button and also staged an avatar
upload in the same save, isAvatarUploadOnlyDirty returned true and
onSubmit short-circuited to avatar-only upload, silently dropping
the PATCH that would persist the skill removal.
The dialog path (SkillSelectDialog) already passes shouldDirty: true
on add/remove; this aligns the inline control with that behavior.
* fix: restore full ACL scope for primeInvokedSkills history reconstruction
Reverting the earlier scoping of primeInvokedSkills to the active-agent
union. That change conflated runtime invocation scoping (which correctly
gates what the model can call now) with history reconstruction (which
restores bodies the model already saw in prior turns).
Per-agent scoping still applies at:
- Catalog injection (injectSkillCatalog via initializeAgent)
- Runtime invocation (handleSkillToolCall via enrichWithSkillConfigurable,
using each agent's scoped accessibleSkillIds in agentToolContexts)
History priming is a read of past context, not a grant of new capability.
Scoping it causes historical skill bodies to vanish from formatAgentMessages
when an agent's skills list is edited mid-conversation or when the ephemeral
toggle flips, which breaks message reconstruction and drops code-env file
continuity for /mnt/data/{skillName}/ references. The user's ACL-accessible
set is the correct and sufficient gate for history reconstruction.
* fix: close openai.js skill gap and pin undefined vs [] semantics
Three related gaps surfaced in review:
1. api/server/controllers/agents/openai.js was a third skill resolution
site alongside responses.js and initialize.js, but still used the old
activation gate (required ephemeralAgent.skills === true) and never
passed accessibleSkillIds through scopeSkillIds. Per-agent scoping
silently did not apply on this route. Mirror the same pattern used
in responses.js so all three routes behave identically.
2. scopeSkillIds previously collapsed undefined and [] into the same
"full catalog" fallback, making it impossible for a user to express
"this agent has no skills." Tighten the semantics before any data
is written under the old behavior:
- undefined / null = not configured, full catalog
- [] = explicitly none, returns []
- non-empty = intersection with ACL-accessible set
Update defaultAgentFormValues.skills from [] to undefined so a brand
new agent whose skills UI was never touched does not accidentally
persist "explicit none" on first save (removeNullishValues strips
undefined from the payload server side).
3. Add direct unit tests for scopeSkillIds covering all five cases
(undefined, null, empty, disjoint, overlap, exact match, empty
accessible set). 16 tests total in skills.test.ts pass.
* fix: add scopeSkillIds to @librechat/api mock in openai unit test
Same pattern as the earlier responses.unit.spec.js fix: the test mocks
@librechat/api with an explicit object, so each newly imported symbol
must be added to the mock. Without scopeSkillIds, OpenAIChatCompletion
controller throws on destructuring before reaching recordCollectedUsage,
causing the token usage assertions to fail.
1072 lines
34 KiB
JavaScript
1072 lines
34 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,
|
|
buildToolSet,
|
|
scopeSkillIds,
|
|
createSafeUser,
|
|
initializeAgent,
|
|
getBalanceConfig,
|
|
recordCollectedUsage,
|
|
getTransactionsConfig,
|
|
createToolExecuteHandler,
|
|
discoverConnectedAgents,
|
|
getRemoteAgentPermissions,
|
|
// Responses API
|
|
writeDone,
|
|
buildResponse,
|
|
generateResponseId,
|
|
isValidationFailure,
|
|
emitResponseCreated,
|
|
createResponseContext,
|
|
createResponseTracker,
|
|
setupStreamingResponse,
|
|
emitResponseInProgress,
|
|
convertInputToMessages,
|
|
validateResponseRequest,
|
|
buildAggregatedResponse,
|
|
createResponseAggregator,
|
|
sendResponsesErrorResponse,
|
|
createResponsesEventHandlers,
|
|
createAggregatorEventHandlers,
|
|
} = require('@librechat/api');
|
|
const {
|
|
createResponsesToolEndCallback,
|
|
buildSummarizationHandlers,
|
|
markSummarizationUsage,
|
|
createToolEndCallback,
|
|
agentLogHandlerObj,
|
|
} = require('~/server/controllers/agents/callbacks');
|
|
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
|
|
const {
|
|
findAccessibleResources,
|
|
getEffectivePermissions,
|
|
} = require('~/server/services/PermissionService');
|
|
const {
|
|
getSkillToolDeps,
|
|
enrichWithSkillConfigurable,
|
|
} = require('~/server/services/Endpoints/agents/skillDeps');
|
|
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
|
const { logViolation } = require('~/cache');
|
|
const db = require('~/models');
|
|
|
|
/**
|
|
* 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,
|
|
}) {
|
|
const agent = { id: agentId, tools, provider, model, tool_options };
|
|
try {
|
|
return await loadAgentTools({
|
|
req,
|
|
res,
|
|
agent,
|
|
signal,
|
|
tool_resources,
|
|
definitionsOnly,
|
|
streamId: null,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error loading tools for agent ' + agentId, 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);
|
|
}
|
|
|
|
/**
|
|
* 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) => {
|
|
const internalMsg = {
|
|
role: msg.isCreatedByUser ? 'user' : 'assistant',
|
|
content: '',
|
|
messageId: msg.messageId,
|
|
};
|
|
|
|
// Handle content - could be string or array
|
|
if (typeof msg.text === 'string') {
|
|
internalMsg.content = msg.text;
|
|
} else if (Array.isArray(msg.content)) {
|
|
// Handle content parts
|
|
internalMsg.content = msg.content;
|
|
} else if (msg.text) {
|
|
internalMsg.content = String(msg.text);
|
|
}
|
|
|
|
return internalMsg;
|
|
});
|
|
} catch (error) {
|
|
logger.error('[Responses API] Error loading previous messages:', 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save the assistant message
|
|
await db.saveMessage(
|
|
req,
|
|
{
|
|
messageId: responseId,
|
|
conversationId,
|
|
parentMessageId: null,
|
|
isCreatedByUser: false,
|
|
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) {
|
|
await db.saveConvo(
|
|
{
|
|
userId: req?.user?.id,
|
|
isTemporary: req?.body?.isTemporary,
|
|
interfaceConfig: req?.config?.interfaceConfig,
|
|
},
|
|
{
|
|
conversationId,
|
|
endpoint: EModelEndpoint.agents,
|
|
agentId,
|
|
title: agent?.name || 'Open Responses Conversation',
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Create Response - POST /v1/responses
|
|
*
|
|
* 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
|
|
*/
|
|
const createResponse = async (req, res) => {
|
|
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 agentId = request.model;
|
|
const isStreaming = request.stream === true;
|
|
const summarizationConfig = req.config?.summarization;
|
|
|
|
// 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',
|
|
);
|
|
}
|
|
if (!(await db.getConvo(req.user?.id, request.previous_response_id))) {
|
|
return sendResponsesErrorResponse(res, 404, 'Conversation not found', 'not_found');
|
|
}
|
|
}
|
|
|
|
const conversationId = request.previous_response_id ?? uuidv4();
|
|
const parentMessageId = null;
|
|
|
|
// Build allowed providers set
|
|
const allowedProviders = new Set(
|
|
req.config?.endpoints?.[EModelEndpoint.agents]?.allowedProviders,
|
|
);
|
|
|
|
// Create tool loader
|
|
const loadTools = createToolLoader(abortController.signal);
|
|
|
|
// Initialize the agent first to check for disableStreaming
|
|
const endpointOption = {
|
|
endpoint: agent.provider,
|
|
model_parameters: agent.model_parameters ?? {},
|
|
};
|
|
|
|
// `filterFilesByAgentAccess` is intentionally omitted: it calls
|
|
// `checkPermission` with `resourceType: AGENT`, but this route
|
|
// authorizes callers through `REMOTE_AGENT` (via
|
|
// `getRemoteAgentPermissions`), so including it would silently drop
|
|
// owner-attached context files for any remote user who has
|
|
// `REMOTE_AGENT_VIEWER` but not direct `AGENT_VIEW`.
|
|
const dbMethods = {
|
|
getConvoFiles: db.getConvoFiles,
|
|
getFiles: db.getFiles,
|
|
getUserKey: db.getUserKey,
|
|
getMessages: db.getMessages,
|
|
updateFilesUsage: db.updateFilesUsage,
|
|
getUserKeyValues: db.getUserKeyValues,
|
|
getUserCodeFiles: db.getUserCodeFiles,
|
|
getToolFilesByIds: db.getToolFilesByIds,
|
|
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
|
|
listSkillsByAccess: db.listSkillsByAccess,
|
|
};
|
|
|
|
const enabledCapabilities = new Set(
|
|
appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities,
|
|
);
|
|
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
|
|
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
|
|
const accessibleSkillIds = skillsCapabilityEnabled
|
|
? await findAccessibleResources({
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.SKILL,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
})
|
|
: [];
|
|
|
|
const primaryConfig = await initializeAgent(
|
|
{
|
|
req,
|
|
res,
|
|
loadTools,
|
|
requestFiles: [],
|
|
conversationId,
|
|
parentMessageId,
|
|
agent,
|
|
endpointOption,
|
|
allowedProviders,
|
|
isInitialAgent: true,
|
|
accessibleSkillIds: scopeSkillIds(
|
|
accessibleSkillIds,
|
|
ephemeralSkillsToggle ? undefined : agent.skills,
|
|
),
|
|
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
|
},
|
|
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,
|
|
* userMCPAuthMap?: Record<string, Record<string, string>>,
|
|
* tool_resources?: object,
|
|
* actionsEnabled?: boolean,
|
|
* }>}
|
|
*/
|
|
const agentToolContexts = new Map();
|
|
agentToolContexts.set(primaryConfig.id, {
|
|
agent,
|
|
toolRegistry: primaryConfig.toolRegistry,
|
|
userMCPAuthMap: primaryConfig.userMCPAuthMap,
|
|
tool_resources: primaryConfig.tool_resources,
|
|
actionsEnabled: primaryConfig.actionsEnabled,
|
|
});
|
|
|
|
// Only run BFS discovery (and pay `getModelsConfig` upfront) when the
|
|
// primary has edges to follow — the common API case is single-agent.
|
|
let handoffAgentConfigs = new Map();
|
|
let discoveredEdges = [];
|
|
let discoveredMCPAuthMap;
|
|
if (primaryConfig.edges?.length) {
|
|
const modelsConfig = await getModelsConfig(req);
|
|
({
|
|
agentConfigs: handoffAgentConfigs,
|
|
edges: discoveredEdges,
|
|
userMCPAuthMap: discoveredMCPAuthMap,
|
|
} = await discoverConnectedAgents(
|
|
{
|
|
req,
|
|
res,
|
|
primaryConfig,
|
|
endpointOption,
|
|
allowedProviders,
|
|
modelsConfig,
|
|
loadTools,
|
|
requestFiles: [],
|
|
conversationId,
|
|
parentMessageId,
|
|
// The route enforces REMOTE_AGENT on the primary; every discovered
|
|
// sub-agent must clear the same sharing boundary, not the looser
|
|
// in-app AGENT one.
|
|
resourceType: ResourceType.REMOTE_AGENT,
|
|
},
|
|
{
|
|
getAgent: db.getAgent,
|
|
// Use `getRemoteAgentPermissions` so sub-agent authorization
|
|
// matches what the route's `createCheckRemoteAgentAccess`
|
|
// middleware does for the primary: AGENT owners with the SHARE
|
|
// bit are treated as remotely authorized even without an
|
|
// explicit REMOTE_AGENT grant.
|
|
checkPermission: async ({ userId, role, resourceId, requiredPermission }) => {
|
|
const permissions = await getRemoteAgentPermissions(
|
|
{ getEffectivePermissions },
|
|
userId,
|
|
role,
|
|
resourceId,
|
|
);
|
|
return hasPermissions(permissions, requiredPermission);
|
|
},
|
|
logViolation,
|
|
db: dbMethods,
|
|
onAgentInitialized: (agentId, handoffAgent, config) => {
|
|
agentToolContexts.set(agentId, {
|
|
agent: handoffAgent,
|
|
toolRegistry: config.toolRegistry,
|
|
userMCPAuthMap: config.userMCPAuthMap,
|
|
tool_resources: config.tool_resources,
|
|
actionsEnabled: config.actionsEnabled,
|
|
});
|
|
},
|
|
initializeAgent,
|
|
},
|
|
));
|
|
}
|
|
|
|
primaryConfig.edges = discoveredEdges;
|
|
const runAgents = [primaryConfig, ...handoffAgentConfigs.values()];
|
|
const mergedMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap;
|
|
|
|
// Determine if streaming is enabled (check both request and agent config)
|
|
const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming;
|
|
const actuallyStreaming = isStreaming && !streamingDisabled;
|
|
|
|
// Load previous messages if previous_response_id is provided
|
|
let previousMessages = [];
|
|
if (request.previous_response_id) {
|
|
const userId = req.user?.id ?? 'api-user';
|
|
previousMessages = await loadPreviousMessages(request.previous_response_id, userId);
|
|
}
|
|
|
|
// Convert input to internal messages
|
|
const inputMessages = convertToInternalMessages(
|
|
typeof request.input === 'string' ? request.input : request.input,
|
|
);
|
|
|
|
// Merge previous messages with new input
|
|
const allMessages = [...previousMessages, ...inputMessages];
|
|
|
|
const toolSet = buildToolSet(primaryConfig);
|
|
const {
|
|
messages: formattedMessages,
|
|
indexTokenCountMap,
|
|
summary: initialSummary,
|
|
} = formatAgentMessages(allMessages, {}, toolSet);
|
|
|
|
// 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,
|
|
toolNames,
|
|
agent: ctx.agent ?? agent,
|
|
signal: abortController.signal,
|
|
toolRegistry: ctx.toolRegistry,
|
|
userMCPAuthMap: ctx.userMCPAuthMap,
|
|
tool_resources: ctx.tool_resources,
|
|
actionsEnabled: ctx.actionsEnabled,
|
|
});
|
|
return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds);
|
|
},
|
|
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 = req.user?.id ?? 'api-user';
|
|
const userMCPAuthMap = mergedMCPAuthMap;
|
|
|
|
const run = await createRun({
|
|
agents: runAgents,
|
|
messages: formattedMessages,
|
|
indexTokenCountMap,
|
|
initialSummary,
|
|
runId: responseId,
|
|
summarizationConfig,
|
|
appConfig: req.config,
|
|
signal: abortController.signal,
|
|
customHandlers: handlers,
|
|
requestBody: {
|
|
messageId: responseId,
|
|
conversationId,
|
|
},
|
|
user: { id: userId },
|
|
});
|
|
|
|
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: {
|
|
messageId: responseId,
|
|
conversationId,
|
|
},
|
|
...(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}"`, error);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Record token usage against balance
|
|
const balanceConfig = getBalanceConfig(req.config);
|
|
const transactionsConfig = getTransactionsConfig(req.config);
|
|
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,
|
|
},
|
|
).catch((err) => {
|
|
logger.error('[Responses API] Error recording usage:', 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:', 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:', 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,
|
|
toolNames,
|
|
agent: ctx.agent ?? agent,
|
|
signal: abortController.signal,
|
|
toolRegistry: ctx.toolRegistry,
|
|
userMCPAuthMap: ctx.userMCPAuthMap,
|
|
tool_resources: ctx.tool_resources,
|
|
actionsEnabled: ctx.actionsEnabled,
|
|
});
|
|
return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds);
|
|
},
|
|
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 = req.user?.id ?? 'api-user';
|
|
const userMCPAuthMap = mergedMCPAuthMap;
|
|
|
|
const run = await createRun({
|
|
agents: runAgents,
|
|
messages: formattedMessages,
|
|
indexTokenCountMap,
|
|
initialSummary,
|
|
runId: responseId,
|
|
summarizationConfig,
|
|
appConfig: req.config,
|
|
signal: abortController.signal,
|
|
customHandlers: handlers,
|
|
requestBody: {
|
|
messageId: responseId,
|
|
conversationId,
|
|
},
|
|
user: { id: userId },
|
|
});
|
|
|
|
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: {
|
|
messageId: responseId,
|
|
conversationId,
|
|
},
|
|
...(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}"`, error);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Record token usage against balance
|
|
const balanceConfig = getBalanceConfig(req.config);
|
|
const transactionsConfig = getTransactionsConfig(req.config);
|
|
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,
|
|
},
|
|
).catch((err) => {
|
|
logger.error('[Responses API] Error recording usage:', err);
|
|
});
|
|
|
|
if (artifactPromises.length > 0) {
|
|
try {
|
|
await Promise.all(artifactPromises);
|
|
} catch (artifactError) {
|
|
logger.warn('[Responses API] Error processing artifacts:', 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:', 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) {
|
|
const errorMessage = error instanceof Error ? error.message : 'An error occurred';
|
|
logger.error('[Responses API] Error:', error);
|
|
|
|
// Check if we already started streaming (headers sent)
|
|
if (res.headersSent) {
|
|
// Headers already sent, write error event and close
|
|
writeDone(res);
|
|
res.end();
|
|
} else {
|
|
// 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';
|
|
sendResponsesErrorResponse(res, statusCode, errorMessage, errorType);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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:', 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:', error);
|
|
sendResponsesErrorResponse(
|
|
res,
|
|
500,
|
|
error instanceof Error ? error.message : 'Failed to get response',
|
|
'server_error',
|
|
);
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
createResponse,
|
|
getResponse,
|
|
listModels,
|
|
};
|