🔄 refactor: Convert OCR Tool Resource to Context (#9699)

* WIP: conversion of `ocr` to `context`

* refactor: make `primeResources` backwards-compatible for `ocr` tool_resources

* refactor: Convert legacy `ocr` tool resource to `context` in agent updates

- Implemented conversion logic to replace `ocr` with `context` in both incoming updates and existing agent data.
- Merged file IDs and files from `ocr` into `context` while ensuring deduplication.
- Updated tools array to reflect the change from `ocr` to `context`.

* refactor: Enhance context file handling in agent processing

- Updated the logic for managing context files by consolidating file IDs from both `ocr` and `context` resources.
- Improved backwards compatibility by ensuring that context files are correctly populated and handled.
- Simplified the iteration over context files for better readability and maintainability.

* refactor: Enhance tool_resources handling in primeResources

- Added tests to verify the deletion behavior of tool_resources fields, ensuring original objects remain unchanged.
- Implemented logic to delete `ocr` and `context` fields after fetching and re-categorizing files.
- Preserved context field when the context capability is disabled, ensuring correct behavior in various scenarios.

* refactor: Replace `ocrEnabled` with `contextEnabled` in AgentConfig

* refactor: Adjust legacy tool handling order for improved clarity

* refactor: Implement OCR to context conversion functions and remove original conversion logic in update agent handling

* refactor: Move contextEnabled declaration to maintain consistent order in capabilities

* refactor: Update localization keys for file context to improve clarity and accuracy

* chore: Update localization key for file context information to improve clarity
This commit is contained in:
Danny Avila 2025-09-18 20:06:59 -04:00 committed by GitHub
parent 89d12a8ccd
commit 81139046e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1281 additions and 76 deletions

View file

@ -42,7 +42,7 @@ const getToolFilesByIds = async (fileIds, toolResourceSet) => {
$or: [], $or: [],
}; };
if (toolResourceSet.has(EToolResources.ocr)) { if (toolResourceSet.has(EToolResources.context)) {
filter.$or.push({ text: { $exists: true, $ne: null }, context: FileContext.agents }); filter.$or.push({ text: { $exists: true, $ne: null }, context: FileContext.agents });
} }
if (toolResourceSet.has(EToolResources.file_search)) { if (toolResourceSet.has(EToolResources.file_search)) {

View file

@ -158,7 +158,7 @@ describe('duplicateAgent', () => {
}); });
}); });
it('should handle tool_resources.ocr correctly', async () => { it('should convert `tool_resources.ocr` to `tool_resources.context`', async () => {
const mockAgent = { const mockAgent = {
id: 'agent_123', id: 'agent_123',
name: 'Test Agent', name: 'Test Agent',
@ -178,7 +178,7 @@ describe('duplicateAgent', () => {
expect(createAgent).toHaveBeenCalledWith( expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
tool_resources: { tool_resources: {
ocr: { enabled: true, config: 'test' }, context: { enabled: true, config: 'test' },
}, },
}), }),
); );

View file

@ -2,7 +2,12 @@ const { z } = require('zod');
const fs = require('fs').promises; const fs = require('fs').promises;
const { nanoid } = require('nanoid'); const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api'); const {
agentCreateSchema,
agentUpdateSchema,
mergeAgentOcrConversion,
convertOcrToContextInPlace,
} = require('@librechat/api');
const { const {
Tools, Tools,
Constants, Constants,
@ -198,19 +203,32 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
* @param {object} req.params - Request params * @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier. * @param {string} req.params.id - Agent identifier.
* @param {AgentUpdateParams} req.body - The Agent update parameters. * @param {AgentUpdateParams} req.body - The Agent update parameters.
* @returns {Agent} 200 - success response - application/json * @returns {Promise<Agent>} 200 - success response - application/json
*/ */
const updateAgentHandler = async (req, res) => { const updateAgentHandler = async (req, res) => {
try { try {
const id = req.params.id; const id = req.params.id;
const validatedData = agentUpdateSchema.parse(req.body); const validatedData = agentUpdateSchema.parse(req.body);
const { _id, ...updateData } = removeNullishValues(validatedData); const { _id, ...updateData } = removeNullishValues(validatedData);
// Convert OCR to context in incoming updateData
convertOcrToContextInPlace(updateData);
const existingAgent = await getAgent({ id }); const existingAgent = await getAgent({ id });
if (!existingAgent) { if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' }); return res.status(404).json({ error: 'Agent not found' });
} }
// Convert legacy OCR tool resource to context format in existing agent
const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
if (ocrConversion.tool_resources) {
updateData.tool_resources = ocrConversion.tool_resources;
}
if (ocrConversion.tools) {
updateData.tools = ocrConversion.tools;
}
let updatedAgent = let updatedAgent =
Object.keys(updateData).length > 0 Object.keys(updateData).length > 0
? await updateAgent({ id }, updateData, { ? await updateAgent({ id }, updateData, {
@ -255,7 +273,7 @@ const updateAgentHandler = async (req, res) => {
* @param {object} req - Express Request * @param {object} req - Express Request
* @param {object} req.params - Request params * @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier. * @param {string} req.params.id - Agent identifier.
* @returns {Agent} 201 - success response - application/json * @returns {Promise<Agent>} 201 - success response - application/json
*/ */
const duplicateAgentHandler = async (req, res) => { const duplicateAgentHandler = async (req, res) => {
const { id } = req.params; const { id } = req.params;
@ -288,9 +306,19 @@ const duplicateAgentHandler = async (req, res) => {
hour12: false, hour12: false,
})})`; })})`;
if (_tool_resources?.[EToolResources.context]) {
cloneData.tool_resources = {
[EToolResources.context]: _tool_resources[EToolResources.context],
};
}
if (_tool_resources?.[EToolResources.ocr]) { if (_tool_resources?.[EToolResources.ocr]) {
cloneData.tool_resources = { cloneData.tool_resources = {
[EToolResources.ocr]: _tool_resources[EToolResources.ocr], /** Legacy conversion from `ocr` to `context` */
[EToolResources.context]: {
...(_tool_resources[EToolResources.context] ?? {}),
..._tool_resources[EToolResources.ocr],
},
}; };
} }
@ -382,7 +410,7 @@ const duplicateAgentHandler = async (req, res) => {
* @param {object} req - Express Request * @param {object} req - Express Request
* @param {object} req.params - Request params * @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier. * @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - success response - application/json * @returns {Promise<Agent>} 200 - success response - application/json
*/ */
const deleteAgentHandler = async (req, res) => { const deleteAgentHandler = async (req, res) => {
try { try {
@ -484,7 +512,7 @@ const getListAgentsHandler = async (req, res) => {
* @param {Express.Multer.File} req.file - The avatar image file. * @param {Express.Multer.File} req.file - The avatar image file.
* @param {object} req.body - Request body * @param {object} req.body - Request body
* @param {string} [req.body.avatar] - Optional avatar for the agent's avatar. * @param {string} [req.body.avatar] - Optional avatar for the agent's avatar.
* @returns {Object} 200 - success response - application/json * @returns {Promise<void>} 200 - success response - application/json
*/ */
const uploadAgentAvatarHandler = async (req, res) => { const uploadAgentAvatarHandler = async (req, res) => {
try { try {

View file

@ -512,6 +512,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
mockReq.params.id = existingAgentId; mockReq.params.id = existingAgentId;
mockReq.body = { mockReq.body = {
tool_resources: { tool_resources: {
/** Legacy conversion from `ocr` to `context` */
ocr: { ocr: {
file_ids: ['ocr1', 'ocr2'], file_ids: ['ocr1', 'ocr2'],
}, },
@ -531,7 +532,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
const updatedAgent = mockRes.json.mock.calls[0][0]; const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.tool_resources).toBeDefined(); expect(updatedAgent.tool_resources).toBeDefined();
expect(updatedAgent.tool_resources.ocr).toBeDefined(); expect(updatedAgent.tool_resources.ocr).toBeUndefined();
expect(updatedAgent.tool_resources.context).toBeDefined();
expect(updatedAgent.tool_resources.execute_code).toBeDefined(); expect(updatedAgent.tool_resources.execute_code).toBeDefined();
expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined(); expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined();
}); });

View file

@ -10,11 +10,12 @@ const { getFiles } = require('~/models/File');
*/ */
const checkAgentBasedFileAccess = async ({ userId, role, fileId }) => { const checkAgentBasedFileAccess = async ({ userId, role, fileId }) => {
try { try {
// Find agents that have this file in their tool_resources /** Agents that have this file in their tool_resources */
const agentsWithFile = await getAgents({ const agentsWithFile = await getAgents({
$or: [ $or: [
{ 'tool_resources.file_search.file_ids': fileId },
{ 'tool_resources.execute_code.file_ids': fileId }, { 'tool_resources.execute_code.file_ids': fileId },
{ 'tool_resources.file_search.file_ids': fileId },
{ 'tool_resources.context.file_ids': fileId },
{ 'tool_resources.ocr.file_ids': fileId }, { 'tool_resources.ocr.file_ids': fileId },
], ],
}); });
@ -83,7 +84,6 @@ const fileAccess = async (req, res, next) => {
}); });
} }
// Get the file
const [file] = await getFiles({ file_id: fileId }); const [file] = await getFiles({ file_id: fileId });
if (!file) { if (!file) {
return res.status(404).json({ return res.status(404).json({
@ -92,20 +92,18 @@ const fileAccess = async (req, res, next) => {
}); });
} }
// Check if user owns the file
if (file.user && file.user.toString() === userId) { if (file.user && file.user.toString() === userId) {
req.fileAccess = { file }; req.fileAccess = { file };
return next(); return next();
} }
// Check agent-based access (file inherits agent permissions) /** Agent-based access (file inherits agent permissions) */
const hasAgentAccess = await checkAgentBasedFileAccess({ userId, role: userRole, fileId }); const hasAgentAccess = await checkAgentBasedFileAccess({ userId, role: userRole, fileId });
if (hasAgentAccess) { if (hasAgentAccess) {
req.fileAccess = { file }; req.fileAccess = { file };
return next(); return next();
} }
// No access
logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`); logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`);
return res.status(403).json({ return res.status(403).json({
error: 'Forbidden', error: 'Forbidden',

View file

@ -552,7 +552,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
throw new Error('File search is not enabled for Agents'); throw new Error('File search is not enabled for Agents');
} }
// Note: File search processing continues to dual storage logic below // Note: File search processing continues to dual storage logic below
} else if (tool_resource === EToolResources.ocr) { } else if (tool_resource === EToolResources.context) {
const { file_id, temp_file_id = null } = metadata; const { file_id, temp_file_id = null } = metadata;
/** /**

View file

@ -353,7 +353,12 @@ async function processRequiredActions(client, requiredActions) {
async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIApiKey }) { async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIApiKey }) {
if (!agent.tools || agent.tools.length === 0) { if (!agent.tools || agent.tools.length === 0) {
return {}; return {};
} else if (agent.tools && agent.tools.length === 1 && agent.tools[0] === AgentCapabilities.ocr) { } else if (
agent.tools &&
agent.tools.length === 1 &&
/** Legacy handling for `ocr` as may still exist in existing Agents */
(agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr)
) {
return {}; return {};
} }

View file

@ -94,11 +94,11 @@ const AttachFileMenu = ({
}, },
]; ];
if (capabilities.ocrEnabled) { if (capabilities.contextEnabled) {
items.push({ items.push({
label: localize('com_ui_upload_ocr_text'), label: localize('com_ui_upload_ocr_text'),
onClick: () => { onClick: () => {
setToolResource(EToolResources.ocr); setToolResource(EToolResources.context);
onAction(); onAction();
}, },
icon: <FileType2Icon className="icon-md" />, icon: <FileType2Icon className="icon-md" />,

View file

@ -64,10 +64,10 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
icon: <TerminalSquareIcon className="icon-md" />, icon: <TerminalSquareIcon className="icon-md" />,
}); });
} }
if (capabilities.ocrEnabled) { if (capabilities.contextEnabled) {
_options.push({ _options.push({
label: localize('com_ui_upload_ocr_text'), label: localize('com_ui_upload_ocr_text'),
value: EToolResources.ocr, value: EToolResources.context,
icon: <FileType2Icon className="icon-md" />, icon: <FileType2Icon className="icon-md" />,
}); });
} }

View file

@ -79,9 +79,9 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
}, [fileMap, agentFiles]); }, [fileMap, agentFiles]);
const { const {
ocrEnabled,
codeEnabled, codeEnabled,
toolsEnabled, toolsEnabled,
contextEnabled,
actionsEnabled, actionsEnabled,
artifactsEnabled, artifactsEnabled,
webSearchEnabled, webSearchEnabled,
@ -291,7 +291,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
{(codeEnabled || {(codeEnabled ||
fileSearchEnabled || fileSearchEnabled ||
artifactsEnabled || artifactsEnabled ||
ocrEnabled || contextEnabled ||
webSearchEnabled) && ( webSearchEnabled) && (
<div className="mb-4 flex w-full flex-col items-start gap-3"> <div className="mb-4 flex w-full flex-col items-start gap-3">
<label className="text-token-text-primary block font-medium"> <label className="text-token-text-primary block font-medium">
@ -301,8 +301,8 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
{codeEnabled && <CodeForm agent_id={agent_id} files={code_files} />} {codeEnabled && <CodeForm agent_id={agent_id} files={code_files} />}
{/* Web Search */} {/* Web Search */}
{webSearchEnabled && <SearchForm />} {webSearchEnabled && <SearchForm />}
{/* File Context (OCR) */} {/* File Context */}
{ocrEnabled && <FileContext agent_id={agent_id} files={context_files} />} {contextEnabled && <FileContext agent_id={agent_id} files={context_files} />}
{/* Artifacts */} {/* Artifacts */}
{artifactsEnabled && <Artifacts />} {artifactsEnabled && <Artifacts />}
{/* File Search */} {/* File Search */}

View file

@ -47,7 +47,7 @@ export default function FileContext({
const { handleFileChange } = useFileHandling({ const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents, overrideEndpoint: EModelEndpoint.agents,
additionalMetadata: { agent_id, tool_resource: EToolResources.ocr }, additionalMetadata: { agent_id, tool_resource: EToolResources.context },
fileSetter: setFiles, fileSetter: setFiles,
}); });
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
@ -113,7 +113,7 @@ export default function FileContext({
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<label className="text-token-text-primary block font-medium"> <label className="text-token-text-primary block font-medium">
{localize('com_agents_file_context')} {localize('com_agents_file_context_label')}
</label> </label>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" /> <CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</span> </span>
@ -122,7 +122,7 @@ export default function FileContext({
<HoverCardContent side={ESide.Top} className="w-80"> <HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
{localize('com_agents_file_context_info')} {localize('com_agents_file_context_description')}
</p> </p>
</div> </div>
</HoverCardContent> </HoverCardContent>
@ -130,13 +130,13 @@ export default function FileContext({
</div> </div>
</HoverCard> </HoverCard>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* File Context (OCR) Files */} {/* File Context Files */}
<FileRow <FileRow
files={files} files={files}
setFiles={setFiles} setFiles={setFiles}
setFilesLoading={setFilesLoading} setFilesLoading={setFilesLoading}
agent_id={agent_id} agent_id={agent_id}
tool_resource={EToolResources.ocr} tool_resource={EToolResources.context}
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>} Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
/> />
<div> <div>

View file

@ -142,7 +142,6 @@ describe('useAgentToolPermissions', () => {
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined }); (useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
const ephemeralAgent = { const ephemeralAgent = {
[EToolResources.ocr]: true,
[EToolResources.file_search]: true, [EToolResources.file_search]: true,
}; };

View file

@ -6,6 +6,7 @@ interface AgentCapabilitiesResult {
actionsEnabled: boolean; actionsEnabled: boolean;
artifactsEnabled: boolean; artifactsEnabled: boolean;
ocrEnabled: boolean; ocrEnabled: boolean;
contextEnabled: boolean;
fileSearchEnabled: boolean; fileSearchEnabled: boolean;
webSearchEnabled: boolean; webSearchEnabled: boolean;
codeEnabled: boolean; codeEnabled: boolean;
@ -34,6 +35,11 @@ export default function useAgentCapabilities(
[capabilities], [capabilities],
); );
const contextEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.context) ?? false,
[capabilities],
);
const fileSearchEnabled = useMemo( const fileSearchEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.file_search) ?? false, () => capabilities?.includes(AgentCapabilities.file_search) ?? false,
[capabilities], [capabilities],
@ -54,6 +60,7 @@ export default function useAgentCapabilities(
codeEnabled, codeEnabled,
toolsEnabled, toolsEnabled,
actionsEnabled, actionsEnabled,
contextEnabled,
artifactsEnabled, artifactsEnabled,
webSearchEnabled, webSearchEnabled,
fileSearchEnabled, fileSearchEnabled,

View file

@ -71,7 +71,7 @@ export default function useDragHelpers() {
const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities; const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities;
const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true; const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true;
const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true; const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true;
const ocrEnabled = capabilities.includes(AgentCapabilities.ocr) === true; const contextEnabled = capabilities.includes(AgentCapabilities.context) === true;
/** Get agent permissions at drop time */ /** Get agent permissions at drop time */
const agentId = conversationRef.current?.agent_id; const agentId = conversationRef.current?.agent_id;
@ -99,7 +99,7 @@ export default function useDragHelpers() {
allImages || allImages ||
(fileSearchEnabled && fileSearchAllowedByAgent) || (fileSearchEnabled && fileSearchAllowedByAgent) ||
(codeEnabled && codeAllowedByAgent) || (codeEnabled && codeAllowedByAgent) ||
ocrEnabled; contextEnabled;
if (!shouldShowModal) { if (!shouldShowModal) {
// Fallback: directly handle files without showing modal // Fallback: directly handle files without showing modal

View file

@ -59,9 +59,9 @@
"com_agents_error_timeout_suggestion": "Please check your internet connection and try again.", "com_agents_error_timeout_suggestion": "Please check your internet connection and try again.",
"com_agents_error_timeout_title": "Connection Timeout", "com_agents_error_timeout_title": "Connection Timeout",
"com_agents_error_title": "Something went wrong", "com_agents_error_title": "Something went wrong",
"com_agents_file_context": "File Context (OCR)", "com_agents_file_context_label": "File Context",
"com_agents_file_context_disabled": "Agent must be created before uploading files for File Context.", "com_agents_file_context_disabled": "Agent must be created before uploading files for File Context.",
"com_agents_file_context_info": "Files uploaded as \"Context\" are processed using OCR to extract text, which is then added to the Agent's instructions. Ideal for documents, images with text, or PDFs where you need the full text content of a file", "com_agents_file_context_description": "Files uploaded as \"Context\" are parsed as text to supplement the Agent's instructions. If OCR is available, or if configured for the uploaded filetype, the process is used to extract text. Ideal for documents, images with text, or PDFs where you need the full text content of a file",
"com_agents_file_search_disabled": "Agent must be created before uploading files for File Search.", "com_agents_file_search_disabled": "Agent must be created before uploading files for File Search.",
"com_agents_file_search_info": "When enabled, the agent will be informed of the exact filenames listed below, allowing it to retrieve relevant context from these files.", "com_agents_file_search_info": "When enabled, the agent will be informed of the exact filenames listed below, allowing it to retrieve relevant context from these files.",
"com_agents_grid_announcement": "Showing {{count}} agents in {{category}} category", "com_agents_grid_announcement": "Showing {{count}} agents in {{category}} category",

View file

@ -253,7 +253,7 @@ export const validateFiles = ({
} }
let mimeTypesToCheck = supportedMimeTypes; let mimeTypesToCheck = supportedMimeTypes;
if (toolResource === EToolResources.ocr) { if (toolResource === EToolResources.context) {
mimeTypesToCheck = [ mimeTypesToCheck = [
...(fileConfig?.text?.supportedMimeTypes || []), ...(fileConfig?.text?.supportedMimeTypes || []),
...(fileConfig?.ocr?.supportedMimeTypes || []), ...(fileConfig?.ocr?.supportedMimeTypes || []),

View file

@ -62,14 +62,19 @@ export const processAgentOption = ({
fileMap?: Record<string, TFile | undefined>; fileMap?: Record<string, TFile | undefined>;
}): TAgentOption => { }): TAgentOption => {
const isGlobal = _agent?.isPublic ?? false; const isGlobal = _agent?.isPublic ?? false;
const context_files = _agent?.tool_resources?.context?.file_ids ?? [];
if (_agent?.tool_resources?.ocr?.file_ids) {
/** Backwards-compatibility */
context_files.push(..._agent.tool_resources.ocr.file_ids);
}
const agent: TAgentOption = { const agent: TAgentOption = {
...(_agent ?? ({} as Agent)), ...(_agent ?? ({} as Agent)),
label: _agent?.name ?? '', label: _agent?.name ?? '',
value: _agent?.id ?? '', value: _agent?.id ?? '',
icon: isGlobal ? <EarthIcon className="icon-md text-green-400" /> : null, icon: isGlobal ? <EarthIcon className="icon-md text-green-400" /> : null,
context_files: _agent?.tool_resources?.ocr?.file_ids context_files: context_files.length > 0 ? ([] as Array<[string, ExtendedFile]>) : undefined,
? ([] as Array<[string, ExtendedFile]>)
: undefined,
knowledge_files: _agent?.tool_resources?.file_search?.file_ids knowledge_files: _agent?.tool_resources?.file_search?.file_ids
? ([] as Array<[string, ExtendedFile]>) ? ([] as Array<[string, ExtendedFile]>)
: undefined, : undefined,
@ -130,12 +135,12 @@ export const processAgentOption = ({
} }
}; };
if (agent.context_files && _agent?.tool_resources?.ocr?.file_ids) { if (agent.context_files && context_files.length > 0) {
_agent.tool_resources.ocr.file_ids.forEach((file_id) => context_files.forEach((file_id) =>
handleFile({ handleFile({
file_id, file_id,
list: agent.context_files, list: agent.context_files,
tool_resource: EToolResources.ocr, tool_resource: EToolResources.context,
}), }),
); );
} }

View file

@ -1,6 +1,7 @@
export * from './config'; export * from './config';
export * from './memory'; export * from './memory';
export * from './migration'; export * from './migration';
export * from './legacy';
export * from './resources'; export * from './resources';
export * from './run'; export * from './run';
export * from './validation'; export * from './validation';

View file

@ -0,0 +1,697 @@
import { EToolResources } from 'librechat-data-provider';
import { convertOcrToContextInPlace, mergeAgentOcrConversion } from './legacy';
import type { AgentToolResources, TFile } from 'librechat-data-provider';
describe('OCR to Context Conversion for updateAgentHandler', () => {
describe('convertOcrToContextInPlace', () => {
it('should do nothing when no OCR resource exists', () => {
const data = {
tool_resources: {
[EToolResources.execute_code]: {
file_ids: ['file1'],
},
},
tools: ['execute_code'],
};
const originalCopy = JSON.parse(JSON.stringify(data));
convertOcrToContextInPlace(data);
expect(data).toEqual(originalCopy);
});
it('should convert OCR to context when context does not exist', () => {
const data = {
tool_resources: {
[EToolResources.ocr]: {
file_ids: ['ocr1', 'ocr2'],
files: [
{
file_id: 'ocr1',
filename: 'doc.pdf',
filepath: '/doc.pdf',
type: 'application/pdf',
user: 'user1',
object: 'file' as const,
bytes: 1024,
embedded: false,
usage: 0,
},
],
},
} as AgentToolResources,
};
convertOcrToContextInPlace(data);
expect(data.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(data.tool_resources?.[EToolResources.context]).toEqual({
file_ids: ['ocr1', 'ocr2'],
files: [
{
file_id: 'ocr1',
filename: 'doc.pdf',
filepath: '/doc.pdf',
type: 'application/pdf',
user: 'user1',
object: 'file',
bytes: 1024,
embedded: false,
usage: 0,
},
],
});
});
it('should merge OCR into existing context', () => {
const data = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['context1'],
files: [
{
file_id: 'context1',
filename: 'existing.txt',
filepath: '/existing.txt',
type: 'text/plain',
user: 'user1',
object: 'file' as const,
bytes: 256,
embedded: false,
usage: 0,
},
],
},
[EToolResources.ocr]: {
file_ids: ['ocr1', 'ocr2'],
files: [
{
file_id: 'ocr1',
filename: 'scan.pdf',
filepath: '/scan.pdf',
type: 'application/pdf',
user: 'user1',
object: 'file' as const,
bytes: 1024,
embedded: false,
usage: 0,
},
],
},
},
};
convertOcrToContextInPlace(data);
expect(data.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(data.tool_resources?.[EToolResources.context]?.file_ids).toEqual([
'context1',
'ocr1',
'ocr2',
]);
expect(data.tool_resources?.[EToolResources.context]?.files).toHaveLength(2);
expect(data.tool_resources?.[EToolResources.context]?.files?.map((f) => f.file_id)).toEqual([
'context1',
'ocr1',
]);
});
it('should deduplicate file_ids when merging', () => {
const data = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['file1', 'file2'],
},
[EToolResources.ocr]: {
file_ids: ['file2', 'file3'],
},
},
};
convertOcrToContextInPlace(data);
expect(data.tool_resources?.[EToolResources.context]?.file_ids).toEqual([
'file1',
'file2',
'file3',
]);
});
it('should deduplicate files by file_id when merging', () => {
const sharedFile: TFile = {
file_id: 'shared',
filename: 'shared.txt',
filepath: '/shared.txt',
type: 'text/plain',
user: 'user1',
object: 'file',
bytes: 256,
embedded: false,
usage: 0,
};
const data = {
tool_resources: {
[EToolResources.context]: {
files: [sharedFile],
},
[EToolResources.ocr]: {
files: [
sharedFile,
{
file_id: 'unique',
filename: 'unique.pdf',
filepath: '/unique.pdf',
type: 'application/pdf',
user: 'user1',
object: 'file' as const,
bytes: 1024,
embedded: false,
usage: 0,
},
],
},
},
};
convertOcrToContextInPlace(data);
expect(data.tool_resources?.[EToolResources.context]?.files).toHaveLength(2);
expect(
data.tool_resources?.[EToolResources.context]?.files?.map((f) => f.file_id).sort(),
).toEqual(['shared', 'unique']);
});
it('should replace OCR with context in tools array', () => {
const data = {
tools: ['execute_code', 'ocr', 'file_search'],
};
convertOcrToContextInPlace(data);
expect(data.tools).toEqual(['execute_code', 'context', 'file_search']);
});
it('should remove duplicates when context already exists in tools', () => {
const data = {
tools: ['context', 'ocr', 'execute_code'],
};
convertOcrToContextInPlace(data);
expect(data.tools).toEqual(['context', 'execute_code']);
});
it('should handle both tool_resources and tools conversion', () => {
const data = {
tool_resources: {
[EToolResources.ocr]: {
file_ids: ['ocr1'],
},
} as AgentToolResources,
tools: ['ocr', 'execute_code'],
};
convertOcrToContextInPlace(data);
expect(data.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(data.tool_resources?.[EToolResources.context]).toEqual({
file_ids: ['ocr1'],
});
expect(data.tools).toEqual(['context', 'execute_code']);
});
it('should preserve other tool resources during OCR conversion', () => {
const data = {
tool_resources: {
[EToolResources.execute_code]: {
file_ids: ['exec1', 'exec2'],
files: [
{
file_id: 'exec1',
filename: 'script.py',
filepath: '/script.py',
type: 'text/x-python',
user: 'user1',
object: 'file' as const,
bytes: 512,
embedded: false,
usage: 0,
},
],
},
[EToolResources.file_search]: {
file_ids: ['search1'],
vector_store_ids: ['vector1', 'vector2'],
},
[EToolResources.ocr]: {
file_ids: ['ocr1'],
},
} as AgentToolResources,
tools: ['execute_code', 'file_search', 'ocr'],
};
const originalExecuteCode = JSON.parse(JSON.stringify(data.tool_resources.execute_code));
const originalFileSearch = JSON.parse(JSON.stringify(data.tool_resources.file_search));
convertOcrToContextInPlace(data);
// OCR should be converted to context
expect(data.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(data.tool_resources?.[EToolResources.context]).toEqual({
file_ids: ['ocr1'],
});
// Other resources should remain unchanged
expect(data.tool_resources?.[EToolResources.execute_code]).toEqual(originalExecuteCode);
expect(data.tool_resources?.[EToolResources.file_search]).toEqual(originalFileSearch);
// Tools array should have ocr replaced with context
expect(data.tools).toEqual(['execute_code', 'file_search', 'context']);
});
it('should preserve image_edit resource during OCR conversion', () => {
const data = {
tool_resources: {
[EToolResources.image_edit]: {
file_ids: ['image1'],
files: [
{
file_id: 'image1',
filename: 'photo.png',
filepath: '/photo.png',
type: 'image/png',
user: 'user1',
object: 'file' as const,
bytes: 2048,
embedded: false,
usage: 0,
width: 800,
height: 600,
},
],
},
[EToolResources.ocr]: {
file_ids: ['ocr1'],
},
} as AgentToolResources,
};
const originalImageEdit = JSON.parse(JSON.stringify(data.tool_resources.image_edit));
convertOcrToContextInPlace(data);
expect(data.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(data.tool_resources?.[EToolResources.context]).toEqual({
file_ids: ['ocr1'],
});
expect(data.tool_resources?.[EToolResources.image_edit]).toEqual(originalImageEdit);
});
});
describe('mergeAgentOcrConversion', () => {
it('should return empty object when existing agent has no OCR', () => {
const existingAgent = {
tool_resources: {
[EToolResources.execute_code]: {
file_ids: ['file1'],
},
},
tools: ['execute_code'],
};
const updateData = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['context1'],
},
},
};
const result = mergeAgentOcrConversion(existingAgent, updateData);
expect(result).toEqual({});
});
it('should convert existing OCR to context when no context exists', () => {
const existingAgent = {
tool_resources: {
[EToolResources.ocr]: {
file_ids: ['ocr1', 'ocr2'],
files: [
{
file_id: 'ocr1',
filename: 'doc.pdf',
filepath: '/doc.pdf',
type: 'application/pdf',
user: 'user1',
object: 'file' as const,
bytes: 1024,
embedded: false,
usage: 0,
},
],
},
},
tools: ['ocr', 'execute_code'],
};
const updateData = {};
const result = mergeAgentOcrConversion(existingAgent, updateData);
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(result.tool_resources?.[EToolResources.context]).toEqual({
file_ids: ['ocr1', 'ocr2'],
files: [
{
file_id: 'ocr1',
filename: 'doc.pdf',
filepath: '/doc.pdf',
type: 'application/pdf',
user: 'user1',
object: 'file',
bytes: 1024,
embedded: false,
usage: 0,
},
],
});
expect(result.tools).toEqual(['context', 'execute_code']);
});
it('should merge existing OCR with existing context', () => {
const existingAgent = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['context1'],
},
[EToolResources.ocr]: {
file_ids: ['ocr1'],
},
},
};
const updateData = {};
const result = mergeAgentOcrConversion(existingAgent, updateData);
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(result.tool_resources?.[EToolResources.context]?.file_ids).toEqual([
'context1',
'ocr1',
]);
});
it('should merge converted context with updateData context', () => {
const existingAgent = {
tool_resources: {
[EToolResources.ocr]: {
file_ids: ['ocr1'],
},
},
};
const updateData = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['update-context1'],
},
},
};
const result = mergeAgentOcrConversion(existingAgent, updateData);
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(result.tool_resources?.[EToolResources.context]?.file_ids?.sort()).toEqual([
'ocr1',
'update-context1',
]);
});
it('should handle complex merge with files and file_ids', () => {
const existingAgent = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['context1'],
files: [
{
file_id: 'context1',
filename: 'existing.txt',
filepath: '/existing.txt',
type: 'text/plain',
user: 'user1',
object: 'file' as const,
bytes: 256,
embedded: false,
usage: 0,
},
],
},
[EToolResources.ocr]: {
file_ids: ['ocr1', 'ocr2'],
files: [
{
file_id: 'ocr1',
filename: 'scan.pdf',
filepath: '/scan.pdf',
type: 'application/pdf',
user: 'user1',
object: 'file' as const,
bytes: 1024,
embedded: false,
usage: 0,
},
],
},
},
tools: ['context', 'ocr'],
};
const updateData = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['update1'],
files: [
{
file_id: 'update1',
filename: 'update.txt',
filepath: '/update.txt',
type: 'text/plain',
user: 'user1',
object: 'file' as const,
bytes: 512,
embedded: false,
usage: 0,
},
],
},
},
};
const result = mergeAgentOcrConversion(existingAgent, updateData);
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(result.tool_resources?.[EToolResources.context]?.file_ids?.sort()).toEqual([
'context1',
'ocr1',
'ocr2',
'update1',
]);
expect(result.tool_resources?.[EToolResources.context]?.files).toHaveLength(3);
expect(result.tools).toEqual(['context']);
});
it('should not mutate original objects', () => {
const existingAgent = {
tool_resources: {
[EToolResources.ocr]: {
file_ids: ['ocr1'],
},
},
tools: ['ocr'],
};
const updateData = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['context1'],
},
},
};
const existingCopy = JSON.parse(JSON.stringify(existingAgent));
const updateCopy = JSON.parse(JSON.stringify(updateData));
mergeAgentOcrConversion(existingAgent, updateData);
expect(existingAgent).toEqual(existingCopy);
expect(updateData).toEqual(updateCopy);
});
it('should preserve other tool resources in existing agent during merge', () => {
const existingAgent = {
tool_resources: {
[EToolResources.execute_code]: {
file_ids: ['exec1', 'exec2'],
files: [
{
file_id: 'exec1',
filename: 'script.py',
filepath: '/script.py',
type: 'text/x-python',
user: 'user1',
object: 'file' as const,
bytes: 512,
embedded: false,
usage: 0,
},
],
},
[EToolResources.file_search]: {
file_ids: ['search1'],
vector_store_ids: ['vector1', 'vector2'],
},
[EToolResources.ocr]: {
file_ids: ['ocr1'],
},
},
tools: ['execute_code', 'file_search', 'ocr'],
};
const updateData = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['new-context1'],
},
},
};
const originalExecuteCode = JSON.parse(
JSON.stringify(existingAgent.tool_resources.execute_code),
);
const originalFileSearch = JSON.parse(
JSON.stringify(existingAgent.tool_resources.file_search),
);
const result = mergeAgentOcrConversion(existingAgent, updateData);
// OCR should be converted to context and merged with updateData context
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(result.tool_resources?.[EToolResources.context]?.file_ids?.sort()).toEqual([
'new-context1',
'ocr1',
]);
// Other resources should be preserved
expect(result.tool_resources?.[EToolResources.execute_code]).toEqual(originalExecuteCode);
expect(result.tool_resources?.[EToolResources.file_search]).toEqual(originalFileSearch);
// Tools should have ocr replaced with context
expect(result.tools).toEqual(['execute_code', 'file_search', 'context']);
});
it('should not affect updateData tool resources that are not context', () => {
const existingAgent = {
tool_resources: {
[EToolResources.ocr]: {
file_ids: ['ocr1'],
},
},
tools: ['ocr'],
};
const updateData = {
tool_resources: {
[EToolResources.execute_code]: {
file_ids: ['update-exec1'],
},
[EToolResources.file_search]: {
file_ids: ['update-search1'],
vector_store_ids: ['update-vector1'],
},
},
tools: ['execute_code', 'file_search'],
};
const originalUpdateData = JSON.parse(JSON.stringify(updateData));
const result = mergeAgentOcrConversion(existingAgent, updateData);
// OCR should be converted to context
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(result.tool_resources?.[EToolResources.context]).toEqual({
file_ids: ['ocr1'],
});
// UpdateData's other resources should not be affected
expect(updateData.tool_resources?.[EToolResources.execute_code]).toEqual(
originalUpdateData.tool_resources.execute_code,
);
expect(updateData.tool_resources?.[EToolResources.file_search]).toEqual(
originalUpdateData.tool_resources.file_search,
);
// Result should only have the converted OCR resources and tools
expect(result.tools).toEqual(['context']);
});
it('should handle all tool resources together', () => {
const existingAgent = {
tool_resources: {
[EToolResources.execute_code]: {
file_ids: ['exec1'],
},
[EToolResources.file_search]: {
file_ids: ['search1'],
vector_store_ids: ['vector1'],
},
[EToolResources.image_edit]: {
file_ids: ['image1'],
},
[EToolResources.context]: {
file_ids: ['existing-context1'],
},
[EToolResources.ocr]: {
file_ids: ['ocr1', 'ocr2'],
},
},
tools: ['execute_code', 'file_search', 'image_edit', 'context', 'ocr'],
};
const updateData = {
tool_resources: {
[EToolResources.context]: {
file_ids: ['update-context1'],
},
},
};
const result = mergeAgentOcrConversion(existingAgent, updateData);
// OCR should be merged with existing context and update context
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(result.tool_resources?.[EToolResources.context]?.file_ids?.sort()).toEqual([
'existing-context1',
'ocr1',
'ocr2',
'update-context1',
]);
// All other resources should be preserved
expect(result.tool_resources?.[EToolResources.execute_code]).toEqual({
file_ids: ['exec1'],
});
expect(result.tool_resources?.[EToolResources.file_search]).toEqual({
file_ids: ['search1'],
vector_store_ids: ['vector1'],
});
expect(result.tool_resources?.[EToolResources.image_edit]).toEqual({
file_ids: ['image1'],
});
// Tools should have ocr replaced with context (no duplicates)
expect(result.tools).toEqual(['execute_code', 'file_search', 'image_edit', 'context']);
});
});
});

View file

@ -0,0 +1,141 @@
import { EToolResources } from 'librechat-data-provider';
import type { AgentToolResources, TFile } from 'librechat-data-provider';
/**
* Converts OCR tool resource to context tool resource in place.
* This modifies the input object directly (used for updateData in the handler).
*
* @param data - Object containing tool_resources and/or tools to convert
* @returns void - modifies the input object directly
*/
export function convertOcrToContextInPlace(data: {
tool_resources?: AgentToolResources;
tools?: string[];
}): void {
// Convert OCR to context in tool_resources
if (data.tool_resources?.ocr) {
if (!data.tool_resources.context) {
data.tool_resources.context = data.tool_resources.ocr;
} else {
// Merge OCR into existing context
if (data.tool_resources.ocr?.file_ids?.length) {
const existingFileIds = data.tool_resources.context.file_ids || [];
const ocrFileIds = data.tool_resources.ocr.file_ids || [];
data.tool_resources.context.file_ids = [...new Set([...existingFileIds, ...ocrFileIds])];
}
if (data.tool_resources.ocr?.files?.length) {
const existingFiles = data.tool_resources.context.files || [];
const ocrFiles = data.tool_resources.ocr.files || [];
const filesMap = new Map<string, TFile>();
[...existingFiles, ...ocrFiles].forEach((file) => {
if (file?.file_id) {
filesMap.set(file.file_id, file);
}
});
data.tool_resources.context.files = Array.from(filesMap.values());
}
}
delete data.tool_resources.ocr;
}
// Convert OCR to context in tools array
if (data.tools?.includes(EToolResources.ocr)) {
data.tools = data.tools.map((tool) =>
tool === EToolResources.ocr ? EToolResources.context : tool,
);
data.tools = [...new Set(data.tools)];
}
}
/**
* Merges tool resources from existing agent with incoming update data,
* converting OCR to context and handling deduplication.
* Used when existing agent has OCR that needs to be converted and merged with updateData.
*
* @param existingAgent - The existing agent data
* @param updateData - The incoming update data
* @returns Object with merged tool_resources and tools
*/
export function mergeAgentOcrConversion(
existingAgent: { tool_resources?: AgentToolResources; tools?: string[] },
updateData: { tool_resources?: AgentToolResources; tools?: string[] },
): { tool_resources?: AgentToolResources; tools?: string[] } {
if (!existingAgent.tool_resources?.ocr) {
return {};
}
const result: { tool_resources?: AgentToolResources; tools?: string[] } = {};
// Convert existing agent's OCR to context
result.tool_resources = { ...existingAgent.tool_resources };
if (!result.tool_resources.context) {
// Simple case: no context exists, just move ocr to context
result.tool_resources.context = result.tool_resources.ocr;
} else {
// Merge case: context already exists, merge both file_ids and files arrays
// Merge file_ids if they exist
if (result.tool_resources.ocr?.file_ids?.length) {
const existingFileIds = result.tool_resources.context.file_ids || [];
const ocrFileIds = result.tool_resources.ocr.file_ids || [];
result.tool_resources.context.file_ids = [...new Set([...existingFileIds, ...ocrFileIds])];
}
// Merge files array if it exists (already fetched files)
if (result.tool_resources.ocr?.files?.length) {
const existingFiles = result.tool_resources.context.files || [];
const ocrFiles = result.tool_resources.ocr?.files || [];
// Merge and deduplicate by file_id
const filesMap = new Map<string, TFile>();
[...existingFiles, ...ocrFiles].forEach((file) => {
if (file?.file_id) {
filesMap.set(file.file_id, file);
}
});
result.tool_resources.context.files = Array.from(filesMap.values());
}
}
// Remove the deprecated ocr resource
delete result.tool_resources.ocr;
// Update tools array: replace 'ocr' with 'context'
if (existingAgent.tools?.includes(EToolResources.ocr)) {
result.tools = existingAgent.tools.map((tool) =>
tool === EToolResources.ocr ? EToolResources.context : tool,
);
// Remove duplicates if context already existed
result.tools = [...new Set(result.tools)];
}
// Merge with any context that might already be in updateData (from incoming OCR conversion)
if (updateData.tool_resources?.context && result.tool_resources.context) {
// Merge the contexts
const mergedContext = { ...result.tool_resources.context };
// Merge file_ids
if (updateData.tool_resources.context.file_ids?.length) {
const existingIds = mergedContext.file_ids || [];
const newIds = updateData.tool_resources.context.file_ids || [];
mergedContext.file_ids = [...new Set([...existingIds, ...newIds])];
}
// Merge files
if (updateData.tool_resources.context.files?.length) {
const existingFiles = mergedContext.files || [];
const newFiles = updateData.tool_resources.context.files || [];
const filesMap = new Map<string, TFile>();
[...existingFiles, ...newFiles].forEach((file) => {
if (file?.file_id) {
filesMap.set(file.file_id, file);
}
});
mergedContext.files = Array.from(filesMap.values());
}
result.tool_resources.context = mergedContext;
}
return result;
}

View file

@ -31,7 +31,7 @@ describe('primeResources', () => {
mockAppConfig = { mockAppConfig = {
endpoints: { endpoints: {
[EModelEndpoint.agents]: { [EModelEndpoint.agents]: {
capabilities: [AgentCapabilities.ocr], capabilities: [AgentCapabilities.context],
} as TAgentsEndpoint, } as TAgentsEndpoint,
}, },
} as AppConfig; } as AppConfig;
@ -43,8 +43,8 @@ describe('primeResources', () => {
requestFileSet = new Set(['file1', 'file2', 'file3']); requestFileSet = new Set(['file1', 'file2', 'file3']);
}); });
describe('when OCR is enabled and tool_resources has OCR file_ids', () => { describe('when `context` capability is enabled and tool_resources has "context" file_ids', () => {
it('should fetch OCR files and include them in attachments', async () => { it('should fetch context files and include them in attachments', async () => {
const mockOcrFiles: TFile[] = [ const mockOcrFiles: TFile[] = [
{ {
user: 'user1', user: 'user1',
@ -62,7 +62,7 @@ describe('primeResources', () => {
mockGetFiles.mockResolvedValue(mockOcrFiles); mockGetFiles.mockResolvedValue(mockOcrFiles);
const tool_resources = { const tool_resources = {
[EToolResources.ocr]: { [EToolResources.context]: {
file_ids: ['ocr-file-1'], file_ids: ['ocr-file-1'],
}, },
}; };
@ -83,16 +83,18 @@ describe('primeResources', () => {
{ userId: undefined, agentId: undefined }, { userId: undefined, agentId: undefined },
); );
expect(result.attachments).toEqual(mockOcrFiles); expect(result.attachments).toEqual(mockOcrFiles);
expect(result.tool_resources).toEqual(tool_resources); // Context field is deleted after files are fetched and re-categorized
// Since the file is not embedded and has no special properties, it won't be categorized
expect(result.tool_resources).toEqual({});
}); });
}); });
describe('when OCR is disabled', () => { describe('when `context` capability is disabled', () => {
it('should not fetch OCR files even if tool_resources has OCR file_ids', async () => { it('should not fetch context files even if tool_resources has context file_ids', async () => {
(mockAppConfig.endpoints![EModelEndpoint.agents] as TAgentsEndpoint).capabilities = []; (mockAppConfig.endpoints![EModelEndpoint.agents] as TAgentsEndpoint).capabilities = [];
const tool_resources = { const tool_resources = {
[EToolResources.ocr]: { [EToolResources.context]: {
file_ids: ['ocr-file-1'], file_ids: ['ocr-file-1'],
}, },
}; };
@ -371,8 +373,60 @@ describe('primeResources', () => {
}); });
}); });
describe('when both OCR and attachments are provided', () => { describe('when both "context" files and "attachments" are provided', () => {
it('should include both OCR files and attachment files', async () => { it('should include both context files and attachment files', async () => {
const mockOcrFiles: TFile[] = [
{
user: 'user1',
file_id: 'ocr-file-1',
filename: 'document.pdf',
filepath: '/uploads/document.pdf',
object: 'file',
type: 'application/pdf',
bytes: 1024,
embedded: false,
usage: 0,
},
];
const mockAttachmentFiles: TFile[] = [
{
user: 'user1',
file_id: 'file1',
filename: 'attachment.txt',
filepath: '/uploads/attachment.txt',
object: 'file',
type: 'text/plain',
bytes: 256,
embedded: false,
usage: 0,
},
];
mockGetFiles.mockResolvedValue(mockOcrFiles);
const attachments = Promise.resolve(mockAttachmentFiles);
const tool_resources = {
[EToolResources.context]: {
file_ids: ['ocr-file-1'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments,
tool_resources,
});
expect(result.attachments).toHaveLength(2);
expect(result.attachments?.[0]?.file_id).toBe('ocr-file-1');
expect(result.attachments?.[1]?.file_id).toBe('file1');
});
it('should include both context (as `ocr` resource) files and attachment files', async () => {
const mockOcrFiles: TFile[] = [ const mockOcrFiles: TFile[] = [
{ {
user: 'user1', user: 'user1',
@ -424,7 +478,7 @@ describe('primeResources', () => {
expect(result.attachments?.[1]?.file_id).toBe('file1'); expect(result.attachments?.[1]?.file_id).toBe('file1');
}); });
it('should prevent duplicate files when same file exists in OCR and attachments', async () => { it('should prevent duplicate files when same file exists in context tool_resource and attachments', async () => {
const sharedFile: TFile = { const sharedFile: TFile = {
user: 'user1', user: 'user1',
file_id: 'shared-file-id', file_id: 'shared-file-id',
@ -457,7 +511,7 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockAttachmentFiles); const attachments = Promise.resolve(mockAttachmentFiles);
const tool_resources = { const tool_resources = {
[EToolResources.ocr]: { [EToolResources.context]: {
file_ids: ['shared-file-id'], file_ids: ['shared-file-id'],
}, },
}; };
@ -500,7 +554,7 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockAttachmentFiles); const attachments = Promise.resolve(mockAttachmentFiles);
const tool_resources = { const tool_resources = {
[EToolResources.ocr]: { [EToolResources.context]: {
file_ids: ['shared-file-id'], file_ids: ['shared-file-id'],
}, },
}; };
@ -569,7 +623,7 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockAttachmentFiles); const attachments = Promise.resolve(mockAttachmentFiles);
const tool_resources = { const tool_resources = {
[EToolResources.ocr]: { [EToolResources.context]: {
file_ids: ['file-1', 'file-2'], file_ids: ['file-1', 'file-2'],
}, },
}; };
@ -583,7 +637,7 @@ describe('primeResources', () => {
tool_resources, tool_resources,
}); });
// Should have 3 files total (2 from OCR + 1 unique from attachments) // Should have 3 files total (2 from context files + 1 unique from attachments)
expect(result.attachments).toHaveLength(3); expect(result.attachments).toHaveLength(3);
// Each file should appear only once // Each file should appear only once
@ -628,7 +682,7 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockAttachmentFiles); const attachments = Promise.resolve(mockAttachmentFiles);
const tool_resources = { const tool_resources = {
[EToolResources.ocr]: { [EToolResources.context]: {
file_ids: ['normal-file'], file_ids: ['normal-file'],
}, },
}; };
@ -801,7 +855,7 @@ describe('primeResources', () => {
); );
}); });
it('should handle complex scenario with OCR, existing tool_resources, and attachments', async () => { it('should handle complex scenario with context files, existing tool_resources, and attachments', async () => {
const ocrFile: TFile = { const ocrFile: TFile = {
user: 'user1', user: 'user1',
file_id: 'ocr-file', file_id: 'ocr-file',
@ -843,11 +897,11 @@ describe('primeResources', () => {
width: 600, width: 600,
}; };
mockGetFiles.mockResolvedValue([ocrFile, existingFile]); // OCR returns both files mockGetFiles.mockResolvedValue([ocrFile, existingFile]); // context returns both files
const attachments = Promise.resolve([existingFile, ocrFile, newFile]); // Attachments has duplicates const attachments = Promise.resolve([existingFile, ocrFile, newFile]); // Attachments has duplicates
const existingToolResources = { const existingToolResources = {
[EToolResources.ocr]: { [EToolResources.context]: {
file_ids: ['ocr-file', 'existing-file'], file_ids: ['ocr-file', 'existing-file'],
}, },
[EToolResources.execute_code]: { [EToolResources.execute_code]: {
@ -899,11 +953,11 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockFiles); const attachments = Promise.resolve(mockFiles);
const error = new Error('Test error'); const error = new Error('Test error');
// Mock getFiles to throw an error when called for OCR // Mock getFiles to throw an error when called for context
mockGetFiles.mockRejectedValue(error); mockGetFiles.mockRejectedValue(error);
const tool_resources = { const tool_resources = {
[EToolResources.ocr]: { [EToolResources.context]: {
file_ids: ['ocr-file-1'], file_ids: ['ocr-file-1'],
}, },
}; };
@ -949,6 +1003,245 @@ describe('primeResources', () => {
}); });
}); });
describe('tool_resources field deletion behavior', () => {
it('should not mutate the original tool_resources object', async () => {
const originalToolResources = {
[EToolResources.context]: {
file_ids: ['context-file-1'],
files: [
{
user: 'user1',
file_id: 'context-file-1',
filename: 'original.txt',
filepath: '/uploads/original.txt',
object: 'file' as const,
type: 'text/plain',
bytes: 256,
embedded: false,
usage: 0,
},
],
},
[EToolResources.ocr]: {
file_ids: ['ocr-file-1'],
},
};
// Create a deep copy to compare later
const originalCopy = JSON.parse(JSON.stringify(originalToolResources));
const mockOcrFiles: TFile[] = [
{
user: 'user1',
file_id: 'ocr-file-1',
filename: 'document.pdf',
filepath: '/uploads/document.pdf',
object: 'file',
type: 'application/pdf',
bytes: 1024,
embedded: true,
usage: 0,
},
];
mockGetFiles.mockResolvedValue(mockOcrFiles);
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
tool_resources: originalToolResources,
});
// Original object should remain unchanged
expect(originalToolResources).toEqual(originalCopy);
// Result should have modifications
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
expect(result.tool_resources?.[EToolResources.context]).toBeUndefined();
expect(result.tool_resources?.[EToolResources.file_search]).toBeDefined();
});
it('should delete ocr field after merging file_ids with context', async () => {
const mockOcrFiles: TFile[] = [
{
user: 'user1',
file_id: 'ocr-file-1',
filename: 'document.pdf',
filepath: '/uploads/document.pdf',
object: 'file',
type: 'application/pdf',
bytes: 1024,
embedded: true, // Will be categorized as file_search
usage: 0,
},
];
mockGetFiles.mockResolvedValue(mockOcrFiles);
const tool_resources = {
[EToolResources.ocr]: {
file_ids: ['ocr-file-1'],
},
[EToolResources.context]: {
file_ids: ['context-file-1'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
tool_resources,
});
// OCR field should be deleted after merging
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
// Context field should also be deleted since files were fetched and re-categorized
expect(result.tool_resources?.[EToolResources.context]).toBeUndefined();
// File should be categorized as file_search based on embedded=true
expect(result.tool_resources?.[EToolResources.file_search]?.files).toHaveLength(1);
expect(result.tool_resources?.[EToolResources.file_search]?.files?.[0]?.file_id).toBe(
'ocr-file-1',
);
// Verify getFiles was called with merged file_ids
expect(mockGetFiles).toHaveBeenCalledWith(
{ file_id: { $in: ['context-file-1', 'ocr-file-1'] } },
{},
{},
{ userId: undefined, agentId: undefined },
);
});
it('should delete context field when fetching and re-categorizing files', async () => {
const mockContextFiles: TFile[] = [
{
user: 'user1',
file_id: 'context-file-1',
filename: 'script.py',
filepath: '/uploads/script.py',
object: 'file',
type: 'text/x-python',
bytes: 512,
embedded: false,
usage: 0,
metadata: {
fileIdentifier: 'python-script',
},
},
{
user: 'user1',
file_id: 'context-file-2',
filename: 'data.txt',
filepath: '/uploads/data.txt',
object: 'file',
type: 'text/plain',
bytes: 256,
embedded: true,
usage: 0,
},
];
mockGetFiles.mockResolvedValue(mockContextFiles);
const tool_resources = {
[EToolResources.context]: {
file_ids: ['context-file-1', 'context-file-2'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
tool_resources,
});
// Context field should be deleted after fetching files
expect(result.tool_resources?.[EToolResources.context]).toBeUndefined();
// Files should be re-categorized based on their properties
expect(result.tool_resources?.[EToolResources.execute_code]?.files).toHaveLength(1);
expect(result.tool_resources?.[EToolResources.execute_code]?.files?.[0]?.file_id).toBe(
'context-file-1',
);
expect(result.tool_resources?.[EToolResources.file_search]?.files).toHaveLength(1);
expect(result.tool_resources?.[EToolResources.file_search]?.files?.[0]?.file_id).toBe(
'context-file-2',
);
});
it('should preserve context field when context capability is disabled', async () => {
// Disable context capability
(mockAppConfig.endpoints![EModelEndpoint.agents] as TAgentsEndpoint).capabilities = [];
const tool_resources = {
[EToolResources.context]: {
file_ids: ['context-file-1'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
tool_resources,
});
// Context field should be preserved when capability is disabled
expect(result.tool_resources?.[EToolResources.context]).toEqual({
file_ids: ['context-file-1'],
});
// getFiles should not have been called
expect(mockGetFiles).not.toHaveBeenCalled();
});
it('should still delete ocr field even when context capability is disabled', async () => {
// Disable context capability
(mockAppConfig.endpoints![EModelEndpoint.agents] as TAgentsEndpoint).capabilities = [];
const tool_resources = {
[EToolResources.ocr]: {
file_ids: ['ocr-file-1'],
},
[EToolResources.context]: {
file_ids: ['context-file-1'],
},
};
const result = await primeResources({
req: mockReq,
appConfig: mockAppConfig,
getFiles: mockGetFiles,
requestFileSet,
attachments: undefined,
tool_resources,
});
// OCR field should still be deleted (merged into context)
expect(result.tool_resources?.[EToolResources.ocr]).toBeUndefined();
// Context field should contain merged file_ids but not be processed
expect(result.tool_resources?.[EToolResources.context]).toEqual({
file_ids: ['context-file-1', 'ocr-file-1'],
});
// getFiles should not have been called since context is disabled
expect(mockGetFiles).not.toHaveBeenCalled();
});
});
describe('edge cases', () => { describe('edge cases', () => {
it('should handle missing appConfig agents endpoint gracefully', async () => { it('should handle missing appConfig agents endpoint gracefully', async () => {
const reqWithoutLocals = {} as ServerRequest & { user?: IUser }; const reqWithoutLocals = {} as ServerRequest & { user?: IUser };
@ -961,14 +1254,14 @@ describe('primeResources', () => {
requestFileSet, requestFileSet,
attachments: undefined, attachments: undefined,
tool_resources: { tool_resources: {
[EToolResources.ocr]: { [EToolResources.context]: {
file_ids: ['ocr-file-1'], file_ids: ['ocr-file-1'],
}, },
}, },
}); });
expect(mockGetFiles).not.toHaveBeenCalled(); expect(mockGetFiles).not.toHaveBeenCalled();
// When appConfig agents endpoint is missing, OCR is disabled // When appConfig agents endpoint is missing, context is disabled
// and no attachments are provided, the function returns undefined // and no attachments are provided, the function returns undefined
expect(result.attachments).toBeUndefined(); expect(result.attachments).toBeUndefined();
}); });

View file

@ -183,18 +183,32 @@ export const primeResources = async ({
const processedResourceFiles = new Set<string>(); const processedResourceFiles = new Set<string>();
/** /**
* The agent's tool resources object that will be updated with categorized files * The agent's tool resources object that will be updated with categorized files
* Initialized from input parameter or empty object if not provided * Create a shallow copy first to avoid mutating the original
*/ */
const tool_resources = _tool_resources ?? {}; const tool_resources: AgentToolResources = { ...(_tool_resources ?? {}) };
// Track existing files in tool_resources to prevent duplicates within resources // Deep copy each resource to avoid mutating nested objects/arrays
for (const [resourceType, resource] of Object.entries(tool_resources)) { for (const [resourceType, resource] of Object.entries(tool_resources)) {
if (resource?.files && Array.isArray(resource.files)) { if (!resource) {
continue;
}
// Deep copy the resource to avoid mutations
tool_resources[resourceType as keyof AgentToolResources] = {
...resource,
// Deep copy arrays to prevent mutations
...(resource.files && { files: [...resource.files] }),
...(resource.file_ids && { file_ids: [...resource.file_ids] }),
...(resource.vector_store_ids && { vector_store_ids: [...resource.vector_store_ids] }),
} as AgentBaseResource;
// Now track existing files
if (resource.files && Array.isArray(resource.files)) {
for (const file of resource.files) { for (const file of resource.files) {
if (file?.file_id) { if (file?.file_id) {
processedResourceFiles.add(`${resourceType}:${file.file_id}`); processedResourceFiles.add(`${resourceType}:${file.file_id}`);
// Files from non-OCR resources should not be added to attachments from _attachments // Files from non-context resources should not be added to attachments from _attachments
if (resourceType !== EToolResources.ocr) { if (resourceType !== EToolResources.context && resourceType !== EToolResources.ocr) {
attachmentFileIds.add(file.file_id); attachmentFileIds.add(file.file_id);
} }
} }
@ -202,14 +216,22 @@ export const primeResources = async ({
} }
} }
const isOCREnabled = ( const isContextEnabled = (
appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities ?? [] appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities ?? []
).includes(AgentCapabilities.ocr); ).includes(AgentCapabilities.context);
if (tool_resources[EToolResources.ocr]?.file_ids && isOCREnabled) { const fileIds = tool_resources[EToolResources.context]?.file_ids ?? [];
const ocrFileIds = tool_resources[EToolResources.ocr]?.file_ids;
if (ocrFileIds != null) {
fileIds.push(...ocrFileIds);
delete tool_resources[EToolResources.ocr];
}
if (fileIds.length > 0 && isContextEnabled) {
delete tool_resources[EToolResources.context];
const context = await getFiles( const context = await getFiles(
{ {
file_id: { $in: tool_resources.ocr.file_ids }, file_id: { $in: fileIds },
}, },
{}, {},
{}, {},

View file

@ -26,6 +26,8 @@ export const agentToolResourcesSchema = z
image_edit: agentBaseResourceSchema.optional(), image_edit: agentBaseResourceSchema.optional(),
execute_code: agentBaseResourceSchema.optional(), execute_code: agentBaseResourceSchema.optional(),
file_search: agentFileResourceSchema.optional(), file_search: agentFileResourceSchema.optional(),
context: agentBaseResourceSchema.optional(),
/** @deprecated Use context instead */
ocr: agentBaseResourceSchema.optional(), ocr: agentBaseResourceSchema.optional(),
}) })
.optional(); .optional();

View file

@ -180,6 +180,7 @@ export enum AgentCapabilities {
web_search = 'web_search', web_search = 'web_search',
artifacts = 'artifacts', artifacts = 'artifacts',
actions = 'actions', actions = 'actions',
context = 'context',
tools = 'tools', tools = 'tools',
chain = 'chain', chain = 'chain',
ocr = 'ocr', ocr = 'ocr',
@ -253,6 +254,7 @@ export const defaultAgentCapabilities = [
AgentCapabilities.web_search, AgentCapabilities.web_search,
AgentCapabilities.artifacts, AgentCapabilities.artifacts,
AgentCapabilities.actions, AgentCapabilities.actions,
AgentCapabilities.context,
AgentCapabilities.tools, AgentCapabilities.tools,
AgentCapabilities.chain, AgentCapabilities.chain,
AgentCapabilities.ocr, AgentCapabilities.ocr,

View file

@ -31,6 +31,7 @@ export enum EToolResources {
execute_code = 'execute_code', execute_code = 'execute_code',
file_search = 'file_search', file_search = 'file_search',
image_edit = 'image_edit', image_edit = 'image_edit',
context = 'context',
ocr = 'ocr', ocr = 'ocr',
} }
@ -182,6 +183,8 @@ export interface AgentToolResources {
[EToolResources.image_edit]?: AgentBaseResource; [EToolResources.image_edit]?: AgentBaseResource;
[EToolResources.execute_code]?: ExecuteCodeResource; [EToolResources.execute_code]?: ExecuteCodeResource;
[EToolResources.file_search]?: AgentFileResource; [EToolResources.file_search]?: AgentFileResource;
[EToolResources.context]?: AgentBaseResource;
/** @deprecated Use context instead */
[EToolResources.ocr]?: AgentBaseResource; [EToolResources.ocr]?: AgentBaseResource;
} }
/** /**