🗂️ 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

@ -12,6 +12,7 @@ const {
resolveHeaders,
createSafeUser,
initializeAgent,
countTokens,
getBalanceConfig,
omitTitleOptions,
getProviderConfig,
@ -31,6 +32,8 @@ const {
hydrateMissingIndexTokenCounts,
injectSkillPrimes,
isSkillPrimeMessage,
collectFileIds,
buildAgentScopedContext,
buildSkillPrimeContentParts,
buildInitialToolSessions,
} = require('@librechat/api');
@ -270,10 +273,15 @@ class AgentClient extends BaseClient {
}))
: []),
];
const sharedRunAttachmentIds = new Set();
if (this.options.attachments) {
const attachments = await this.options.attachments;
const latestMessage = orderedMessages[orderedMessages.length - 1];
for (const fileId of collectFileIds(attachments)) {
sharedRunAttachmentIds.add(fileId);
}
if (this.message_file_map) {
this.message_file_map[latestMessage.messageId] = attachments;
} else {
@ -402,6 +410,14 @@ class AgentClient extends BaseClient {
const sharedRunContext = sharedRunContextParts.join('\n\n');
const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory);
const agentScopedContext = await buildAgentScopedContext({
agentIds: allAgents.map(({ agentId }) => agentId),
attachmentsByAgentId: this.options.agentContextAttachmentsByAgentId,
sharedRunAttachmentIds,
req: this.options.req,
tokenCountFn: (text) => countTokens(text),
});
/** Preserve canonical pre-format token counts for all history entering graph formatting */
this.indexTokenCountMap = canonicalTokenCountMap;
@ -439,10 +455,14 @@ class AgentClient extends BaseClient {
await Promise.all(
allAgents.map(({ agent, agentId }) => {
const agentRunContext =
memoryContext && (agentId === this.options.agent.id || memoryAgentEnabled)
? [sharedRunContext, memoryContext].filter(Boolean).join('\n\n')
: sharedRunContext;
const agentRunContextParts = [sharedRunContext];
if (memoryContext && (agentId === this.options.agent.id || memoryAgentEnabled)) {
agentRunContextParts.push(memoryContext);
}
const scopedContext = agentScopedContext.get(agentId);
if (scopedContext) {
agentRunContextParts.push(scopedContext);
}
return applyContextToAgent({
agent,
@ -450,7 +470,7 @@ class AgentClient extends BaseClient {
logger,
mcpManager,
configServers,
sharedRunContext: agentRunContext,
sharedRunContext: agentRunContextParts.filter(Boolean).join('\n\n'),
ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined,
});
}),

View file

@ -13,6 +13,8 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
checkAccess: jest.fn(),
countFormattedMessageTokens: jest.fn(() => 42),
countTokens: jest.fn((text) => Math.ceil(String(text ?? '').length / 4)),
initializeAgent: jest.fn(),
createMemoryProcessor: jest.fn(),
isMemoryAgentEnabled: jest.fn((config) => {
@ -1429,6 +1431,183 @@ describe('AgentClient - titleConvo', () => {
});
});
describe('buildMessages with request and agent-scoped context attachments', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
const makeTextFile = (file_id, filename, text) => ({
user: 'user-123',
file_id,
filename,
filepath: `/uploads/${filename}`,
object: 'file',
type: 'text/plain',
bytes: text.length,
embedded: false,
usage: 0,
source: 'text',
text,
});
beforeEach(() => {
jest.clearAllMocks();
mockFormatInstructions.mockResolvedValue('');
mockAgent = {
id: 'primary-agent',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Primary instructions',
model_parameters: {
model: 'gpt-4',
},
tools: [],
};
mockReq = {
user: {
id: 'user-123',
personalization: {
memories: true,
},
},
body: {
endpoint: EModelEndpoint.openAI,
fileTokenLimit: 1000,
},
config: {
memory: {
disabled: true,
},
},
};
mockRes = {};
client = new AgentClient({
req: mockReq,
res: mockRes,
agent: mockAgent,
endpoint: EModelEndpoint.agents,
});
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
client.shouldSummarize = false;
client.maxContextTokens = 4096;
client.useMemory = jest.fn().mockResolvedValue(undefined);
});
it("applies shared request context plus each agent's own context docs only", async () => {
const requestFile = makeTextFile('request-file', 'request.txt', 'Shared request context');
const primaryContext = makeTextFile(
'primary-context',
'primary.txt',
'Primary private context',
);
const handoffContext = makeTextFile(
'handoff-context',
'handoff.txt',
'Handoff private context',
);
const handoffAgent = {
id: 'handoff-agent',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Handoff instructions',
model_parameters: {
model: 'gpt-4',
},
tools: [],
};
client.options.attachments = [requestFile];
client.options.agentContextAttachmentsByAgentId = new Map([
['primary-agent', [primaryContext]],
['handoff-agent', [handoffContext]],
]);
client.agentConfigs = new Map([['handoff-agent', handoffAgent]]);
await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Use the available context.',
isCreatedByUser: true,
},
],
'msg-1',
{},
);
expect(mockAgent.additional_instructions).toContain('Shared request context');
expect(mockAgent.additional_instructions).toContain('Primary private context');
expect(mockAgent.additional_instructions).not.toContain('Handoff private context');
expect(handoffAgent.additional_instructions).toContain('Shared request context');
expect(handoffAgent.additional_instructions).toContain('Handoff private context');
expect(handoffAgent.additional_instructions).not.toContain('Primary private context');
});
it('does not duplicate a file that is both request context and scoped context', async () => {
const sharedFile = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context');
client.options.attachments = [sharedFile];
client.options.agentContextAttachmentsByAgentId = new Map([['primary-agent', [sharedFile]]]);
client.agentConfigs = new Map();
await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Use the available context.',
isCreatedByUser: true,
},
],
'msg-1',
{},
);
const occurrences = (
mockAgent.additional_instructions.match(/Shared duplicate context/g) ?? []
).length;
expect(occurrences).toBe(1);
});
it('keeps direct chats with context-doc agents working without request attachments', async () => {
const primaryContext = makeTextFile(
'primary-context',
'primary.txt',
'Direct primary context',
);
client.options.agentContextAttachmentsByAgentId = new Map([
['primary-agent', [primaryContext]],
]);
client.agentConfigs = new Map();
await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Answer from your context.',
isCreatedByUser: true,
},
],
'msg-1',
{},
);
expect(mockAgent.additional_instructions).toContain('Direct primary context');
});
});
describe('runMemory method', () => {
let client;
let mockReq;