mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🗂️ fix: Scope Handoff Agent Context Docs (#13167)
* fix: Scope agent context docs to handoff agents * fix: Deduplicate scoped request context * refactor: Extract agent attachment helpers
This commit is contained in:
parent
394839a76b
commit
68eac104ad
12 changed files with 529 additions and 17 deletions
|
|
@ -502,6 +502,45 @@ describe('initializeAgent — stable and dynamic instruction fields', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('initializeAgent — attachment scoping', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps request attachments separate from agent context attachments', async () => {
|
||||
const { primeResources } = jest.requireMock('../resources') as {
|
||||
primeResources: jest.Mock;
|
||||
};
|
||||
const requestFile = { file_id: 'request-file', filename: 'request.txt' };
|
||||
const agentContextFile = { file_id: 'agent-context-file', filename: 'agent-context.txt' };
|
||||
primeResources.mockResolvedValueOnce({
|
||||
attachments: [agentContextFile, requestFile],
|
||||
requestAttachments: [requestFile],
|
||||
agentContextAttachments: [agentContextFile],
|
||||
tool_resources: undefined,
|
||||
});
|
||||
|
||||
const { agent, req, res, loadTools, db } = createMocks();
|
||||
|
||||
const result = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.OPENAI]),
|
||||
isInitialAgent: true,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
expect(result.attachments).toEqual([agentContextFile, requestFile]);
|
||||
expect(result.requestAttachments).toEqual([requestFile]);
|
||||
expect(result.agentContextAttachments).toEqual([agentContextFile]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeAgent — maxContextTokens', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
|
|||
81
packages/api/src/agents/attachments.test.ts
Normal file
81
packages/api/src/agents/attachments.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { FileSources } from 'librechat-data-provider';
|
||||
import type { IMongoFile } from '@librechat/data-schemas';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import {
|
||||
collectFileIds,
|
||||
buildAgentScopedContext,
|
||||
getAgentContextAttachments,
|
||||
buildAgentContextAttachmentsByAgentId,
|
||||
} from './attachments';
|
||||
|
||||
const makeTextFile = (file_id: string, filename: string, text: string): IMongoFile =>
|
||||
({
|
||||
file_id,
|
||||
filename,
|
||||
text,
|
||||
source: FileSources.text,
|
||||
}) as IMongoFile;
|
||||
|
||||
describe('agent attachment helpers', () => {
|
||||
it('collects file ids from attachment-like files', () => {
|
||||
const fileIds = collectFileIds([
|
||||
{ file_id: 'file-1' },
|
||||
null,
|
||||
{ file_id: '' },
|
||||
{ file_id: 'file-2' },
|
||||
{ file_id: 'file-1' },
|
||||
]);
|
||||
|
||||
expect(Array.from(fileIds)).toEqual(['file-1', 'file-2']);
|
||||
});
|
||||
|
||||
it('builds an agent context attachment map from initialized configs', () => {
|
||||
const file = makeTextFile('context-file', 'context.txt', 'context');
|
||||
const attachmentsByAgentId = buildAgentContextAttachmentsByAgentId([
|
||||
{ id: 'agent-a', agentContextAttachments: [file] },
|
||||
{ id: 'agent-b', agentContextAttachments: [] },
|
||||
{ id: null, agentContextAttachments: [file] },
|
||||
undefined,
|
||||
]);
|
||||
|
||||
expect(attachmentsByAgentId.size).toBe(1);
|
||||
expect(attachmentsByAgentId.get('agent-a')).toEqual([file]);
|
||||
});
|
||||
|
||||
it('filters shared request files out of scoped context attachments', () => {
|
||||
const shared = makeTextFile('shared-file', 'shared.txt', 'shared');
|
||||
const scoped = makeTextFile('scoped-file', 'scoped.txt', 'scoped');
|
||||
|
||||
const attachments = getAgentContextAttachments({
|
||||
agentId: 'agent-a',
|
||||
attachmentsByAgentId: new Map([['agent-a', [shared, scoped]]]),
|
||||
excludeFileIds: new Set(['shared-file']),
|
||||
});
|
||||
|
||||
expect(attachments).toEqual([scoped]);
|
||||
});
|
||||
|
||||
it('builds scoped context only from non-shared context documents', async () => {
|
||||
const shared = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context');
|
||||
const scoped = makeTextFile('scoped-file', 'scoped.txt', 'Scoped private context');
|
||||
const req = {
|
||||
body: { fileTokenLimit: 1000 },
|
||||
config: {},
|
||||
} as ServerRequest;
|
||||
|
||||
const scopedContext = await buildAgentScopedContext({
|
||||
agentIds: ['agent-a', 'agent-b'],
|
||||
attachmentsByAgentId: new Map([
|
||||
['agent-a', [shared, scoped]],
|
||||
['agent-b', [shared]],
|
||||
]),
|
||||
sharedRunAttachmentIds: new Set(['shared-file']),
|
||||
req,
|
||||
tokenCountFn: (text) => text.length,
|
||||
});
|
||||
|
||||
expect(scopedContext.get('agent-a')).toContain('Scoped private context');
|
||||
expect(scopedContext.get('agent-a')).not.toContain('Shared duplicate context');
|
||||
expect(scopedContext.has('agent-b')).toBe(false);
|
||||
});
|
||||
});
|
||||
112
packages/api/src/agents/attachments.ts
Normal file
112
packages/api/src/agents/attachments.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { IMongoFile } from '@librechat/data-schemas';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import type { TokenCountFn } from '~/utils/text';
|
||||
import { countTokens } from '~/utils/tokenizer';
|
||||
import { extractFileContext } from '~/files';
|
||||
|
||||
type FileWithId = {
|
||||
file_id?: string | null;
|
||||
};
|
||||
|
||||
export type AgentContextAttachmentCarrier<TFile extends FileWithId = IMongoFile> = {
|
||||
id?: string | null;
|
||||
agentContextAttachments?: TFile[] | null;
|
||||
};
|
||||
|
||||
export type AgentContextAttachmentsByAgentId<TFile extends FileWithId = IMongoFile> =
|
||||
| Map<string, TFile[]>
|
||||
| Record<string, TFile[] | undefined>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export function collectFileIds<TFile extends FileWithId>(
|
||||
files?: Array<TFile | null | undefined> | null,
|
||||
): Set<string> {
|
||||
const fileIds = new Set<string>();
|
||||
for (const file of files ?? []) {
|
||||
if (file?.file_id) {
|
||||
fileIds.add(file.file_id);
|
||||
}
|
||||
}
|
||||
return fileIds;
|
||||
}
|
||||
|
||||
export function buildAgentContextAttachmentsByAgentId<TFile extends FileWithId>(
|
||||
configs: Iterable<AgentContextAttachmentCarrier<TFile> | null | undefined>,
|
||||
): Map<string, TFile[]> {
|
||||
const attachmentsByAgentId = new Map<string, TFile[]>();
|
||||
|
||||
for (const config of configs) {
|
||||
if (!config?.id || !Array.isArray(config.agentContextAttachments)) {
|
||||
continue;
|
||||
}
|
||||
if (config.agentContextAttachments.length === 0) {
|
||||
continue;
|
||||
}
|
||||
attachmentsByAgentId.set(config.id, config.agentContextAttachments);
|
||||
}
|
||||
|
||||
return attachmentsByAgentId;
|
||||
}
|
||||
|
||||
export function getAgentContextAttachments<TFile extends FileWithId>({
|
||||
agentId,
|
||||
attachmentsByAgentId,
|
||||
excludeFileIds,
|
||||
}: {
|
||||
agentId: string;
|
||||
attachmentsByAgentId: AgentContextAttachmentsByAgentId<TFile>;
|
||||
excludeFileIds?: Set<string>;
|
||||
}): TFile[] {
|
||||
if (!attachmentsByAgentId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachments: TFile[] =
|
||||
attachmentsByAgentId instanceof Map
|
||||
? (attachmentsByAgentId.get(agentId) ?? [])
|
||||
: (attachmentsByAgentId[agentId] ?? []);
|
||||
|
||||
if (!excludeFileIds || excludeFileIds.size === 0) {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
return attachments.filter((file) => !file?.file_id || !excludeFileIds.has(file.file_id));
|
||||
}
|
||||
|
||||
export async function buildAgentScopedContext({
|
||||
agentIds,
|
||||
attachmentsByAgentId,
|
||||
sharedRunAttachmentIds,
|
||||
req,
|
||||
tokenCountFn = countTokens,
|
||||
}: {
|
||||
agentIds: string[];
|
||||
attachmentsByAgentId: AgentContextAttachmentsByAgentId<IMongoFile>;
|
||||
sharedRunAttachmentIds?: Set<string>;
|
||||
req?: ServerRequest;
|
||||
tokenCountFn?: TokenCountFn;
|
||||
}): Promise<Map<string, string>> {
|
||||
const uniqueAgentIds = Array.from(new Set(agentIds.filter(Boolean)));
|
||||
const entries = await Promise.all(
|
||||
uniqueAgentIds.map(async (agentId) => {
|
||||
const attachments = getAgentContextAttachments({
|
||||
agentId,
|
||||
attachmentsByAgentId,
|
||||
excludeFileIds: sharedRunAttachmentIds,
|
||||
});
|
||||
if (attachments.length === 0) {
|
||||
return [agentId, ''] as const;
|
||||
}
|
||||
|
||||
const context = await extractFileContext({
|
||||
attachments,
|
||||
req,
|
||||
tokenCountFn,
|
||||
});
|
||||
return [agentId, context ?? ''] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
return new Map(entries.filter(([, context]) => Boolean(context)));
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './avatars';
|
||||
export * from './attachments';
|
||||
export * from './chain';
|
||||
export * from './client';
|
||||
export * from './config';
|
||||
|
|
|
|||
|
|
@ -117,7 +117,12 @@ function resolveAnthropicToolConflicts({
|
|||
*/
|
||||
export type InitializedAgent = Agent & {
|
||||
tools: GenericTool[];
|
||||
/** @deprecated use requestAttachments or agentContextAttachments based on sharing semantics. */
|
||||
attachments: IMongoFile[];
|
||||
/** Files attached to the current user message/run and safe to share across run agents. */
|
||||
requestAttachments: IMongoFile[];
|
||||
/** Files attached to this agent's permanent context via tool_resources. */
|
||||
agentContextAttachments: IMongoFile[];
|
||||
toolContextMap: Record<string, unknown>;
|
||||
dynamicToolContextMap?: Record<string, unknown>;
|
||||
maxContextTokens: number;
|
||||
|
|
@ -535,7 +540,12 @@ export async function initializeAgent(
|
|||
});
|
||||
}
|
||||
|
||||
const { attachments: primedAttachments, tool_resources } = await primeResources({
|
||||
const {
|
||||
attachments: primedAttachments,
|
||||
requestAttachments: primedRequestAttachments,
|
||||
agentContextAttachments: primedAgentContextAttachments,
|
||||
tool_resources,
|
||||
} = await primeResources({
|
||||
req: req as never,
|
||||
getFiles: db.getFiles as never,
|
||||
filterFiles: db.filterFilesByAgentAccess,
|
||||
|
|
@ -959,9 +969,17 @@ export async function initializeAgent(
|
|||
const maxOutputTokensNum = Number(maxOutputTokens) || 0;
|
||||
const baseContextTokens = Math.max(0, agentMaxContextNum - maxOutputTokensNum);
|
||||
|
||||
const finalAttachments: IMongoFile[] = (primedAttachments ?? [])
|
||||
.filter((a): a is TFile => a != null)
|
||||
.map((a) => a as unknown as IMongoFile);
|
||||
const toMongoFiles = (files: Array<TFile | undefined> | undefined): IMongoFile[] =>
|
||||
(files ?? []).filter((a): a is TFile => a != null).map((a) => a as unknown as IMongoFile);
|
||||
|
||||
const finalAttachments: IMongoFile[] = toMongoFiles(primedAttachments);
|
||||
const requestAttachments: IMongoFile[] = toMongoFiles(primedRequestAttachments);
|
||||
const agentContextAttachments: IMongoFile[] = toMongoFiles(primedAgentContextAttachments);
|
||||
|
||||
const compatibilityAttachments =
|
||||
finalAttachments.length > 0
|
||||
? finalAttachments
|
||||
: requestAttachments.concat(agentContextAttachments);
|
||||
|
||||
const endpointConfigs = req.config?.endpoints;
|
||||
const providerConfig =
|
||||
|
|
@ -992,7 +1010,9 @@ export async function initializeAgent(
|
|||
activeSkillNames,
|
||||
manualSkillPrimes,
|
||||
alwaysApplySkillPrimes,
|
||||
attachments: finalAttachments,
|
||||
attachments: compatibilityAttachments,
|
||||
requestAttachments,
|
||||
agentContextAttachments,
|
||||
toolContextMap: toolContextMap ?? {},
|
||||
dynamicToolContextMap: dynamicToolContextMap ?? {},
|
||||
useLegacyContent: !!options.useLegacyContent,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ describe('primeResources', () => {
|
|||
agentId: 'agent_test',
|
||||
});
|
||||
expect(result.attachments).toEqual(mockOcrFiles);
|
||||
expect(result.agentContextAttachments).toEqual(mockOcrFiles);
|
||||
expect(result.requestAttachments).toBeUndefined();
|
||||
expect(result.tool_resources).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
@ -423,6 +425,8 @@ describe('primeResources', () => {
|
|||
expect(result.attachments).toHaveLength(2);
|
||||
expect(result.attachments?.[0]?.file_id).toBe('ocr-file-1');
|
||||
expect(result.attachments?.[1]?.file_id).toBe('file1');
|
||||
expect(result.agentContextAttachments).toEqual(mockOcrFiles);
|
||||
expect(result.requestAttachments).toEqual(mockAttachmentFiles);
|
||||
});
|
||||
|
||||
it('should include both context (as `ocr` resource) files and attachment files', async () => {
|
||||
|
|
@ -475,6 +479,8 @@ describe('primeResources', () => {
|
|||
expect(result.attachments).toHaveLength(2);
|
||||
expect(result.attachments?.[0]?.file_id).toBe('ocr-file-1');
|
||||
expect(result.attachments?.[1]?.file_id).toBe('file1');
|
||||
expect(result.agentContextAttachments).toEqual(mockOcrFiles);
|
||||
expect(result.requestAttachments).toEqual(mockAttachmentFiles);
|
||||
});
|
||||
|
||||
it('should prevent duplicate files when same file exists in context tool_resource and attachments', async () => {
|
||||
|
|
@ -528,6 +534,8 @@ describe('primeResources', () => {
|
|||
expect(result.attachments).toHaveLength(2);
|
||||
expect(result.attachments?.filter((f) => f?.file_id === 'shared-file-id')).toHaveLength(1);
|
||||
expect(result.attachments?.find((f) => f?.file_id === 'unique-file')).toBeDefined();
|
||||
expect(result.agentContextAttachments).toEqual(mockOcrFiles);
|
||||
expect(result.requestAttachments).toEqual(mockAttachmentFiles);
|
||||
});
|
||||
|
||||
it('should still categorize duplicate files for tool_resources', async () => {
|
||||
|
|
|
|||
|
|
@ -174,8 +174,12 @@ export const primeResources = async ({
|
|||
agentId?: string;
|
||||
}): Promise<{
|
||||
attachments: Array<TFile | undefined> | undefined;
|
||||
requestAttachments: Array<TFile | undefined> | undefined;
|
||||
agentContextAttachments: Array<TFile | undefined> | undefined;
|
||||
tool_resources: AgentToolResources | undefined;
|
||||
}> => {
|
||||
const requestAttachments: Array<TFile> = [];
|
||||
const agentContextAttachments: Array<TFile> = [];
|
||||
try {
|
||||
/**
|
||||
* Array to collect all unique files that will be returned as attachments
|
||||
|
|
@ -269,6 +273,7 @@ export const primeResources = async ({
|
|||
|
||||
// Add to attachments
|
||||
attachments.push(file);
|
||||
agentContextAttachments.push(file);
|
||||
attachmentFileIds.add(file.file_id);
|
||||
|
||||
// Categorize for tool resources
|
||||
|
|
@ -282,10 +287,17 @@ export const primeResources = async ({
|
|||
}
|
||||
|
||||
if (!_attachments) {
|
||||
return { attachments: attachments.length > 0 ? attachments : undefined, tool_resources };
|
||||
return {
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
requestAttachments: undefined,
|
||||
agentContextAttachments:
|
||||
agentContextAttachments.length > 0 ? agentContextAttachments : undefined,
|
||||
tool_resources,
|
||||
};
|
||||
}
|
||||
|
||||
const files = await _attachments;
|
||||
const requestAttachmentFileIds = new Set<string>();
|
||||
|
||||
for (const file of files) {
|
||||
if (!file) {
|
||||
|
|
@ -300,16 +312,30 @@ export const primeResources = async ({
|
|||
});
|
||||
|
||||
if (file.file_id && attachmentFileIds.has(file.file_id)) {
|
||||
if (!requestAttachmentFileIds.has(file.file_id)) {
|
||||
requestAttachments.push(file);
|
||||
requestAttachmentFileIds.add(file.file_id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
attachments.push(file);
|
||||
if (!file.file_id || !requestAttachmentFileIds.has(file.file_id)) {
|
||||
requestAttachments.push(file);
|
||||
}
|
||||
if (file.file_id) {
|
||||
attachmentFileIds.add(file.file_id);
|
||||
requestAttachmentFileIds.add(file.file_id);
|
||||
}
|
||||
}
|
||||
|
||||
return { attachments: attachments.length > 0 ? attachments : [], tool_resources };
|
||||
return {
|
||||
attachments: attachments.length > 0 ? attachments : [],
|
||||
requestAttachments,
|
||||
agentContextAttachments:
|
||||
agentContextAttachments.length > 0 ? agentContextAttachments : undefined,
|
||||
tool_resources,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error priming resources', error);
|
||||
|
||||
|
|
@ -328,6 +354,9 @@ export const primeResources = async ({
|
|||
|
||||
return {
|
||||
attachments: safeAttachments,
|
||||
requestAttachments: safeAttachments,
|
||||
agentContextAttachments:
|
||||
agentContextAttachments.length > 0 ? agentContextAttachments : undefined,
|
||||
tool_resources: _tool_resources,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { FileSources, mergeFileConfig } from 'librechat-data-provider';
|
|||
import type { IMongoFile } from '@librechat/data-schemas';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import { processTextWithTokenLimit } from '~/utils/text';
|
||||
import type { TokenCountFn } from '~/utils/text';
|
||||
|
||||
/**
|
||||
* Extracts text context from attachments and returns formatted text.
|
||||
|
|
@ -20,7 +21,7 @@ export async function extractFileContext({
|
|||
}: {
|
||||
attachments: IMongoFile[];
|
||||
req?: ServerRequest;
|
||||
tokenCountFn: (text: string) => number;
|
||||
tokenCountFn: TokenCountFn;
|
||||
}): Promise<string | undefined> {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return undefined;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue