mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +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
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue