🔄 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

@ -1,6 +1,7 @@
export * from './config';
export * from './memory';
export * from './migration';
export * from './legacy';
export * from './resources';
export * from './run';
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 = {
endpoints: {
[EModelEndpoint.agents]: {
capabilities: [AgentCapabilities.ocr],
capabilities: [AgentCapabilities.context],
} as TAgentsEndpoint,
},
} as AppConfig;
@ -43,8 +43,8 @@ describe('primeResources', () => {
requestFileSet = new Set(['file1', 'file2', 'file3']);
});
describe('when OCR is enabled and tool_resources has OCR file_ids', () => {
it('should fetch OCR files and include them in attachments', async () => {
describe('when `context` capability is enabled and tool_resources has "context" file_ids', () => {
it('should fetch context files and include them in attachments', async () => {
const mockOcrFiles: TFile[] = [
{
user: 'user1',
@ -62,7 +62,7 @@ describe('primeResources', () => {
mockGetFiles.mockResolvedValue(mockOcrFiles);
const tool_resources = {
[EToolResources.ocr]: {
[EToolResources.context]: {
file_ids: ['ocr-file-1'],
},
};
@ -83,16 +83,18 @@ describe('primeResources', () => {
{ userId: undefined, agentId: undefined },
);
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', () => {
it('should not fetch OCR files even if tool_resources has OCR file_ids', async () => {
describe('when `context` capability is disabled', () => {
it('should not fetch context files even if tool_resources has context file_ids', async () => {
(mockAppConfig.endpoints![EModelEndpoint.agents] as TAgentsEndpoint).capabilities = [];
const tool_resources = {
[EToolResources.ocr]: {
[EToolResources.context]: {
file_ids: ['ocr-file-1'],
},
};
@ -371,8 +373,60 @@ describe('primeResources', () => {
});
});
describe('when both OCR and attachments are provided', () => {
it('should include both OCR files and attachment files', async () => {
describe('when both "context" files and "attachments" are provided', () => {
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[] = [
{
user: 'user1',
@ -424,7 +478,7 @@ describe('primeResources', () => {
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 = {
user: 'user1',
file_id: 'shared-file-id',
@ -457,7 +511,7 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockAttachmentFiles);
const tool_resources = {
[EToolResources.ocr]: {
[EToolResources.context]: {
file_ids: ['shared-file-id'],
},
};
@ -500,7 +554,7 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockAttachmentFiles);
const tool_resources = {
[EToolResources.ocr]: {
[EToolResources.context]: {
file_ids: ['shared-file-id'],
},
};
@ -569,7 +623,7 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockAttachmentFiles);
const tool_resources = {
[EToolResources.ocr]: {
[EToolResources.context]: {
file_ids: ['file-1', 'file-2'],
},
};
@ -583,7 +637,7 @@ describe('primeResources', () => {
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);
// Each file should appear only once
@ -628,7 +682,7 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockAttachmentFiles);
const tool_resources = {
[EToolResources.ocr]: {
[EToolResources.context]: {
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 = {
user: 'user1',
file_id: 'ocr-file',
@ -843,11 +897,11 @@ describe('primeResources', () => {
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 existingToolResources = {
[EToolResources.ocr]: {
[EToolResources.context]: {
file_ids: ['ocr-file', 'existing-file'],
},
[EToolResources.execute_code]: {
@ -899,11 +953,11 @@ describe('primeResources', () => {
const attachments = Promise.resolve(mockFiles);
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);
const tool_resources = {
[EToolResources.ocr]: {
[EToolResources.context]: {
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', () => {
it('should handle missing appConfig agents endpoint gracefully', async () => {
const reqWithoutLocals = {} as ServerRequest & { user?: IUser };
@ -961,14 +1254,14 @@ describe('primeResources', () => {
requestFileSet,
attachments: undefined,
tool_resources: {
[EToolResources.ocr]: {
[EToolResources.context]: {
file_ids: ['ocr-file-1'],
},
},
});
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
expect(result.attachments).toBeUndefined();
});

View file

@ -183,18 +183,32 @@ export const primeResources = async ({
const processedResourceFiles = new Set<string>();
/**
* 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)) {
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) {
if (file?.file_id) {
processedResourceFiles.add(`${resourceType}:${file.file_id}`);
// Files from non-OCR resources should not be added to attachments from _attachments
if (resourceType !== EToolResources.ocr) {
// Files from non-context resources should not be added to attachments from _attachments
if (resourceType !== EToolResources.context && resourceType !== EToolResources.ocr) {
attachmentFileIds.add(file.file_id);
}
}
@ -202,14 +216,22 @@ export const primeResources = async ({
}
}
const isOCREnabled = (
const isContextEnabled = (
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(
{
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(),
execute_code: agentBaseResourceSchema.optional(),
file_search: agentFileResourceSchema.optional(),
context: agentBaseResourceSchema.optional(),
/** @deprecated Use context instead */
ocr: agentBaseResourceSchema.optional(),
})
.optional();

View file

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

View file

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