mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-08 11:38:51 +01:00
🫱🏼🫲🏽 refactor: Improve Agent Handoffs (#11172)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix: Tool Resources Dropped between Agent Handoffs * fix: agent deletion process to remove handoff edges - Added logic to the `deleteAgent` function to remove references to the deleted agent from other agents' handoff edges. - Implemented error handling to log any issues encountered during the edge removal process. - Introduced a new test case to verify that handoff edges are correctly removed when an agent is deleted, ensuring data integrity across agent relationships. * fix: Improve agent loading process by handling orphaned references - Added logic to track and log agents that fail to load during initialization, preventing errors from interrupting the process. - Introduced a Set to store skipped agent IDs and updated edge filtering to exclude these orphaned references, enhancing data integrity in agent relationships. * chore: Update @librechat/agents to version 3.0.62 * feat: Enhance agent initialization with edge collection and filtering - Introduced new functions for edge collection and filtering orphaned edges, improving the agent loading process. - Refactored the `initializeClient` function to utilize breadth-first search (BFS) for discovering connected agents, enabling transitive handoffs. - Added a new module for edge-related utilities, including deduplication and participant extraction, to streamline edge management. - Updated the agent configuration handling to ensure proper edge processing and integrity during initialization. * refactor: primary agent ID selection for multi-agent conversations - Added a new function `findPrimaryAgentId` to determine the primary agent ID from a set of agent IDs based on suffix rules. - Updated `createMultiAgentMapper` to filter messages by primary agent for parallel agents and handle handoffs appropriately. - Enhanced message processing logic to ensure correct inclusion of agent content based on group and agent ID presence. - Improved documentation to clarify the distinctions between parallel execution and handoff scenarios. * feat: Implement primary agent ID selection for multi-agent content filtering * chore: Update @librechat/agents to version 3.0.63 in package.json and package-lock.json * chore: Update @librechat/agents to version 3.0.64 in package.json and package-lock.json * chore: Update @librechat/agents to version 3.0.65 in package.json and package-lock.json * feat: Add optional agent name to run creation for improved identification * chore: Update @librechat/agents to version 3.0.66 in package.json and package-lock.json * test: Add unit tests for edge utilities including key generation, participant extraction, and orphaned edge filtering - Implemented tests for `getEdgeKey`, `getEdgeParticipants`, `filterOrphanedEdges`, and `createEdgeCollector` functions. - Ensured comprehensive coverage for various edge cases, including handling of arrays and default values. - Verified correct behavior of edge filtering based on skipped agents and deduplication of edges.
This commit is contained in:
parent
d3b5020dd9
commit
791dab8f20
13 changed files with 521 additions and 71 deletions
|
|
@ -87,7 +87,7 @@
|
|||
"@azure/storage-blob": "^12.27.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.80",
|
||||
"@librechat/agents": "^3.0.61",
|
||||
"@librechat/agents": "^3.0.66",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@smithy/node-http-handler": "^4.4.5",
|
||||
|
|
|
|||
257
packages/api/src/agents/edges.spec.ts
Normal file
257
packages/api/src/agents/edges.spec.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import type { GraphEdge } from 'librechat-data-provider';
|
||||
import { getEdgeKey, getEdgeParticipants, filterOrphanedEdges, createEdgeCollector } from './edges';
|
||||
|
||||
describe('edges utilities', () => {
|
||||
describe('getEdgeKey', () => {
|
||||
it('should create key from simple string from/to', () => {
|
||||
const edge: GraphEdge = { from: 'agent_a', to: 'agent_b', edgeType: 'handoff' };
|
||||
expect(getEdgeKey(edge)).toBe('agent_a=>agent_b::handoff');
|
||||
});
|
||||
|
||||
it('should default edgeType to handoff when not provided', () => {
|
||||
const edge = { from: 'agent_a', to: 'agent_b' } as GraphEdge;
|
||||
expect(getEdgeKey(edge)).toBe('agent_a=>agent_b::handoff');
|
||||
});
|
||||
|
||||
it('should handle array from values by sorting', () => {
|
||||
const edge: GraphEdge = { from: ['agent_b', 'agent_a'], to: 'agent_c', edgeType: 'handoff' };
|
||||
expect(getEdgeKey(edge)).toBe('agent_a|agent_b=>agent_c::handoff');
|
||||
});
|
||||
|
||||
it('should handle array to values by sorting', () => {
|
||||
const edge: GraphEdge = { from: 'agent_a', to: ['agent_c', 'agent_b'], edgeType: 'handoff' };
|
||||
expect(getEdgeKey(edge)).toBe('agent_a=>agent_b|agent_c::handoff');
|
||||
});
|
||||
|
||||
it('should handle both from and to as arrays', () => {
|
||||
const edge: GraphEdge = {
|
||||
from: ['agent_b', 'agent_a'],
|
||||
to: ['agent_d', 'agent_c'],
|
||||
edgeType: 'direct',
|
||||
};
|
||||
expect(getEdgeKey(edge)).toBe('agent_a|agent_b=>agent_c|agent_d::direct');
|
||||
});
|
||||
|
||||
it('should produce same key regardless of array order', () => {
|
||||
const edge1: GraphEdge = { from: ['a', 'b', 'c'], to: 'd', edgeType: 'handoff' };
|
||||
const edge2: GraphEdge = { from: ['c', 'a', 'b'], to: 'd', edgeType: 'handoff' };
|
||||
expect(getEdgeKey(edge1)).toBe(getEdgeKey(edge2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEdgeParticipants', () => {
|
||||
it('should return both from and to as participants', () => {
|
||||
const edge: GraphEdge = { from: 'agent_a', to: 'agent_b', edgeType: 'handoff' };
|
||||
expect(getEdgeParticipants(edge)).toEqual(['agent_a', 'agent_b']);
|
||||
});
|
||||
|
||||
it('should handle array from values', () => {
|
||||
const edge: GraphEdge = { from: ['agent_a', 'agent_b'], to: 'agent_c', edgeType: 'handoff' };
|
||||
expect(getEdgeParticipants(edge)).toEqual(['agent_a', 'agent_b', 'agent_c']);
|
||||
});
|
||||
|
||||
it('should handle array to values', () => {
|
||||
const edge: GraphEdge = { from: 'agent_a', to: ['agent_b', 'agent_c'], edgeType: 'handoff' };
|
||||
expect(getEdgeParticipants(edge)).toEqual(['agent_a', 'agent_b', 'agent_c']);
|
||||
});
|
||||
|
||||
it('should handle both from and to as arrays', () => {
|
||||
const edge: GraphEdge = {
|
||||
from: ['agent_a', 'agent_b'],
|
||||
to: ['agent_c', 'agent_d'],
|
||||
edgeType: 'handoff',
|
||||
};
|
||||
expect(getEdgeParticipants(edge)).toEqual(['agent_a', 'agent_b', 'agent_c', 'agent_d']);
|
||||
});
|
||||
|
||||
it('should return empty array for edge with no valid ids', () => {
|
||||
const edge = { from: undefined, to: undefined } as unknown as GraphEdge;
|
||||
expect(getEdgeParticipants(edge)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterOrphanedEdges', () => {
|
||||
const edges: GraphEdge[] = [
|
||||
{ from: 'agent_a', to: 'agent_b', edgeType: 'handoff' },
|
||||
{ from: 'agent_a', to: 'agent_c', edgeType: 'handoff' },
|
||||
{ from: 'agent_b', to: 'agent_d', edgeType: 'handoff' },
|
||||
];
|
||||
|
||||
it('should return all edges when no agents are skipped', () => {
|
||||
const skipped = new Set<string>();
|
||||
expect(filterOrphanedEdges(edges, skipped)).toEqual(edges);
|
||||
});
|
||||
|
||||
it('should filter out edges with orphaned to targets', () => {
|
||||
const skipped = new Set(['agent_b']);
|
||||
const result = filterOrphanedEdges(edges, skipped);
|
||||
// Only filters edges where `to` is in skipped set
|
||||
// agent_a -> agent_b (filtered - to=agent_b is skipped)
|
||||
// agent_a -> agent_c (kept - to=agent_c not skipped)
|
||||
// agent_b -> agent_d (kept - to=agent_d not skipped, from is not checked)
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((e) => e.to)).toEqual(['agent_c', 'agent_d']);
|
||||
});
|
||||
|
||||
it('should filter out multiple orphaned edges', () => {
|
||||
const skipped = new Set(['agent_b', 'agent_c']);
|
||||
const result = filterOrphanedEdges(edges, skipped);
|
||||
// agent_a -> agent_b (filtered)
|
||||
// agent_a -> agent_c (filtered)
|
||||
// agent_b -> agent_d (kept - to=agent_d not skipped)
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].to).toBe('agent_d');
|
||||
});
|
||||
|
||||
it('should handle array to values', () => {
|
||||
const edgesWithArray: GraphEdge[] = [
|
||||
{ from: 'agent_a', to: ['agent_b', 'agent_c'], edgeType: 'handoff' },
|
||||
{ from: 'agent_a', to: ['agent_d'], edgeType: 'handoff' },
|
||||
];
|
||||
const skipped = new Set(['agent_b']);
|
||||
const result = filterOrphanedEdges(edgesWithArray, skipped);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].to).toEqual(['agent_d']);
|
||||
});
|
||||
|
||||
it('should return original edges array when edges is null/undefined', () => {
|
||||
const skipped = new Set(['agent_b']);
|
||||
expect(filterOrphanedEdges(null as unknown as GraphEdge[], skipped)).toBeNull();
|
||||
expect(filterOrphanedEdges(undefined as unknown as GraphEdge[], skipped)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return original edges when skippedAgentIds is empty', () => {
|
||||
const skipped = new Set<string>();
|
||||
expect(filterOrphanedEdges(edges, skipped)).toBe(edges);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEdgeCollector', () => {
|
||||
it('should collect edges and track new agents to process', () => {
|
||||
const initializedAgents = new Set(['primary']);
|
||||
const checkAgentInit = (id: string) => initializedAgents.has(id);
|
||||
const skippedAgentIds = new Set<string>();
|
||||
|
||||
const { edgeMap, agentsToProcess, collectEdges } = createEdgeCollector(
|
||||
checkAgentInit,
|
||||
skippedAgentIds,
|
||||
);
|
||||
|
||||
const edges: GraphEdge[] = [
|
||||
{ from: 'primary', to: 'agent_a', edgeType: 'handoff' },
|
||||
{ from: 'primary', to: 'agent_b', edgeType: 'handoff' },
|
||||
];
|
||||
|
||||
collectEdges(edges);
|
||||
|
||||
expect(edgeMap.size).toBe(2);
|
||||
expect(agentsToProcess.has('agent_a')).toBe(true);
|
||||
expect(agentsToProcess.has('agent_b')).toBe(true);
|
||||
expect(agentsToProcess.has('primary')).toBe(false);
|
||||
});
|
||||
|
||||
it('should deduplicate edges by key', () => {
|
||||
const checkAgentInit = () => false;
|
||||
const skippedAgentIds = new Set<string>();
|
||||
|
||||
const { edgeMap, collectEdges } = createEdgeCollector(checkAgentInit, skippedAgentIds);
|
||||
|
||||
const edges: GraphEdge[] = [
|
||||
{ from: 'agent_a', to: 'agent_b', edgeType: 'handoff' },
|
||||
{ from: 'agent_a', to: 'agent_b', edgeType: 'handoff' },
|
||||
];
|
||||
|
||||
collectEdges(edges);
|
||||
|
||||
expect(edgeMap.size).toBe(1);
|
||||
});
|
||||
|
||||
it('should not add skipped agents to process queue', () => {
|
||||
const checkAgentInit = () => false;
|
||||
const skippedAgentIds = new Set(['agent_b']);
|
||||
|
||||
const { agentsToProcess, collectEdges } = createEdgeCollector(
|
||||
checkAgentInit,
|
||||
skippedAgentIds,
|
||||
);
|
||||
|
||||
const edges: GraphEdge[] = [
|
||||
{ from: 'agent_a', to: 'agent_b', edgeType: 'handoff' },
|
||||
{ from: 'agent_a', to: 'agent_c', edgeType: 'handoff' },
|
||||
];
|
||||
|
||||
collectEdges(edges);
|
||||
|
||||
expect(agentsToProcess.has('agent_a')).toBe(true);
|
||||
expect(agentsToProcess.has('agent_b')).toBe(false);
|
||||
expect(agentsToProcess.has('agent_c')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not add already initialized agents to process queue', () => {
|
||||
const initializedAgents = new Set(['agent_a', 'agent_b']);
|
||||
const checkAgentInit = (id: string) => initializedAgents.has(id);
|
||||
const skippedAgentIds = new Set<string>();
|
||||
|
||||
const { agentsToProcess, collectEdges } = createEdgeCollector(
|
||||
checkAgentInit,
|
||||
skippedAgentIds,
|
||||
);
|
||||
|
||||
const edges: GraphEdge[] = [
|
||||
{ from: 'agent_a', to: 'agent_b', edgeType: 'handoff' },
|
||||
{ from: 'agent_a', to: 'agent_c', edgeType: 'handoff' },
|
||||
];
|
||||
|
||||
collectEdges(edges);
|
||||
|
||||
expect(agentsToProcess.has('agent_a')).toBe(false);
|
||||
expect(agentsToProcess.has('agent_b')).toBe(false);
|
||||
expect(agentsToProcess.has('agent_c')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle undefined/empty edge list', () => {
|
||||
const checkAgentInit = () => false;
|
||||
const skippedAgentIds = new Set<string>();
|
||||
|
||||
const { edgeMap, agentsToProcess, collectEdges } = createEdgeCollector(
|
||||
checkAgentInit,
|
||||
skippedAgentIds,
|
||||
);
|
||||
|
||||
collectEdges(undefined);
|
||||
expect(edgeMap.size).toBe(0);
|
||||
expect(agentsToProcess.size).toBe(0);
|
||||
|
||||
collectEdges([]);
|
||||
expect(edgeMap.size).toBe(0);
|
||||
expect(agentsToProcess.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should support multiple collectEdges calls (BFS pattern)', () => {
|
||||
const initializedAgents = new Set(['primary']);
|
||||
const checkAgentInit = (id: string) => initializedAgents.has(id);
|
||||
const skippedAgentIds = new Set<string>();
|
||||
|
||||
const { edgeMap, agentsToProcess, collectEdges } = createEdgeCollector(
|
||||
checkAgentInit,
|
||||
skippedAgentIds,
|
||||
);
|
||||
|
||||
// First call - primary's edges
|
||||
collectEdges([{ from: 'primary', to: 'agent_a', edgeType: 'handoff' }]);
|
||||
|
||||
expect(edgeMap.size).toBe(1);
|
||||
expect(agentsToProcess.has('agent_a')).toBe(true);
|
||||
|
||||
// Simulate processing agent_a
|
||||
initializedAgents.add('agent_a');
|
||||
agentsToProcess.delete('agent_a');
|
||||
|
||||
// Second call - agent_a's edges (transitive handoff)
|
||||
collectEdges([{ from: 'agent_a', to: 'agent_b', edgeType: 'handoff' }]);
|
||||
|
||||
expect(edgeMap.size).toBe(2);
|
||||
expect(agentsToProcess.has('agent_b')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
90
packages/api/src/agents/edges.ts
Normal file
90
packages/api/src/agents/edges.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { GraphEdge } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Creates a stable key for edge deduplication.
|
||||
* Handles both single and array-based from/to values.
|
||||
*/
|
||||
export function getEdgeKey(edge: GraphEdge): string {
|
||||
const from = Array.isArray(edge.from) ? [...edge.from].sort().join('|') : edge.from;
|
||||
const to = Array.isArray(edge.to) ? [...edge.to].sort().join('|') : edge.to;
|
||||
const type = edge.edgeType ?? 'handoff';
|
||||
return `${from}=>${to}::${type}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all agent IDs referenced in an edge (both from and to).
|
||||
*/
|
||||
export function getEdgeParticipants(edge: GraphEdge): string[] {
|
||||
const participants: string[] = [];
|
||||
if (Array.isArray(edge.from)) {
|
||||
participants.push(...edge.from);
|
||||
} else if (typeof edge.from === 'string') {
|
||||
participants.push(edge.from);
|
||||
}
|
||||
if (Array.isArray(edge.to)) {
|
||||
participants.push(...edge.to);
|
||||
} else if (typeof edge.to === 'string') {
|
||||
participants.push(edge.to);
|
||||
}
|
||||
return participants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out edges that reference non-existent (orphaned) agents.
|
||||
* Only filters based on the 'to' field since those are the handoff targets.
|
||||
*/
|
||||
export function filterOrphanedEdges(edges: GraphEdge[], skippedAgentIds: Set<string>): GraphEdge[] {
|
||||
if (!edges || skippedAgentIds.size === 0) {
|
||||
return edges;
|
||||
}
|
||||
return edges.filter((edge) => {
|
||||
const toIds = Array.isArray(edge.to) ? edge.to : [edge.to];
|
||||
return !toIds.some((id) => typeof id === 'string' && skippedAgentIds.has(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of discovering and aggregating edges from connected agents.
|
||||
*/
|
||||
export interface EdgeDiscoveryResult {
|
||||
/** Deduplicated edges from all discovered agents */
|
||||
edges: GraphEdge[];
|
||||
/** Agent IDs that were not found (orphaned references) */
|
||||
skippedAgentIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects and deduplicates edges, tracking new agents to process.
|
||||
* Used for BFS discovery of connected agents.
|
||||
*/
|
||||
export function createEdgeCollector(
|
||||
checkAgentInit: (agentId: string) => boolean,
|
||||
skippedAgentIds: Set<string>,
|
||||
): {
|
||||
edgeMap: Map<string, GraphEdge>;
|
||||
agentsToProcess: Set<string>;
|
||||
collectEdges: (edgeList: GraphEdge[] | undefined) => void;
|
||||
} {
|
||||
const edgeMap = new Map<string, GraphEdge>();
|
||||
const agentsToProcess = new Set<string>();
|
||||
|
||||
const collectEdges = (edgeList: GraphEdge[] | undefined): void => {
|
||||
if (!edgeList || edgeList.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const edge of edgeList) {
|
||||
const key = getEdgeKey(edge);
|
||||
if (!edgeMap.has(key)) {
|
||||
edgeMap.set(key, edge);
|
||||
}
|
||||
const participants = getEdgeParticipants(edge);
|
||||
for (const id of participants) {
|
||||
if (!checkAgentInit(id) && !skippedAgentIds.has(id)) {
|
||||
agentsToProcess.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { edgeMap, agentsToProcess, collectEdges };
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './chain';
|
||||
export * from './edges';
|
||||
export * from './initialize';
|
||||
export * from './legacy';
|
||||
export * from './memory';
|
||||
|
|
|
|||
|
|
@ -150,7 +150,13 @@ export async function initializeAgent(
|
|||
const provider = agent.provider;
|
||||
agent.endpoint = provider;
|
||||
|
||||
if (isInitialAgent && conversationId != null && resendFiles) {
|
||||
/**
|
||||
* Load conversation files for ALL agents, not just the initial agent.
|
||||
* This enables handoff agents to access files that were uploaded earlier
|
||||
* in the conversation. Without this, file_search and execute_code tools
|
||||
* on handoff agents would fail to find previously attached files.
|
||||
*/
|
||||
if (conversationId != null && resendFiles) {
|
||||
const fileIds = (await db.getConvoFiles(conversationId)) ?? [];
|
||||
const toolResourceSet = new Set<EToolResources>();
|
||||
for (const tool of agent.tools ?? []) {
|
||||
|
|
@ -162,7 +168,7 @@ export async function initializeAgent(
|
|||
if (requestFiles.length || toolFiles.length) {
|
||||
currentFiles = (await db.updateFilesUsage(requestFiles.concat(toolFiles))) as IMongoFile[];
|
||||
}
|
||||
} else if (isInitialAgent && requestFiles.length) {
|
||||
} else if (requestFiles.length) {
|
||||
currentFiles = (await db.updateFilesUsage(requestFiles)) as IMongoFile[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ export async function createRun({
|
|||
provider,
|
||||
reasoningKey,
|
||||
agentId: agent.id,
|
||||
name: agent.name ?? undefined,
|
||||
tools: agent.tools,
|
||||
clientOptions: llmConfig,
|
||||
instructions: systemContent,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue