🗂️ 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:
Danny Avila 2026-05-18 15:36:22 -04:00 committed by GitHub
parent 394839a76b
commit 68eac104ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 529 additions and 17 deletions

View file

@ -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();

View 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);
});
});

View 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)));
}

View file

@ -1,4 +1,5 @@
export * from './avatars';
export * from './attachments';
export * from './chain';
export * from './client';
export * from './config';

View file

@ -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,

View file

@ -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 () => {

View file

@ -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,
};
}

View file

@ -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;