🫱🏼🫲🏽 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

* 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:
Danny Avila 2026-01-01 16:02:51 -05:00 committed by GitHub
parent d3b5020dd9
commit 791dab8f20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 521 additions and 71 deletions

View file

@ -595,6 +595,11 @@ const deleteAgent = async (searchParameter) => {
resourceType: ResourceType.AGENT,
resourceId: agent._id,
});
try {
await Agent.updateMany({ 'edges.to': agent.id }, { $pull: { edges: { to: agent.id } } });
} catch (error) {
logger.error('[deleteAgent] Error removing agent from handoff edges', error);
}
}
return agent;
};

View file

@ -532,6 +532,49 @@ describe('models/Agent', () => {
expect(aclEntriesAfter).toHaveLength(0);
});
test('should remove handoff edges referencing deleted agent from other agents', async () => {
const authorId = new mongoose.Types.ObjectId();
const targetAgentId = `agent_${uuidv4()}`;
const sourceAgentId = `agent_${uuidv4()}`;
// Create target agent (handoff destination)
await createAgent({
id: targetAgentId,
name: 'Target Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create source agent with handoff edge to target
await createAgent({
id: sourceAgentId,
name: 'Source Agent',
provider: 'test',
model: 'test-model',
author: authorId,
edges: [
{
from: sourceAgentId,
to: targetAgentId,
edgeType: 'handoff',
},
],
});
// Verify edge exists before deletion
const sourceAgentBefore = await getAgent({ id: sourceAgentId });
expect(sourceAgentBefore.edges).toHaveLength(1);
expect(sourceAgentBefore.edges[0].to).toBe(targetAgentId);
// Delete the target agent
await deleteAgent({ id: targetAgentId });
// Verify the edge is removed from source agent
const sourceAgentAfter = await getAgent({ id: sourceAgentId });
expect(sourceAgentAfter.edges).toHaveLength(0);
});
test('should list agents by author', async () => {
const authorId = new mongoose.Types.ObjectId();
const otherAuthorId = new mongoose.Types.ObjectId();

View file

@ -45,7 +45,7 @@
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.0.61",
"@librechat/agents": "^3.0.66",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

View file

@ -98,12 +98,42 @@ function logToolError(graph, error, toolId) {
/** Regex pattern to match agent ID suffix (____N) */
const AGENT_SUFFIX_PATTERN = /____(\d+)$/;
/**
* Finds the primary agent ID within a set of agent IDs.
* Primary = no suffix (____N) or lowest suffix number.
* @param {Set<string>} agentIds
* @returns {string | null}
*/
function findPrimaryAgentId(agentIds) {
let primaryAgentId = null;
let lowestSuffixIndex = Infinity;
for (const agentId of agentIds) {
const suffixMatch = agentId.match(AGENT_SUFFIX_PATTERN);
if (!suffixMatch) {
return agentId;
}
const suffixIndex = parseInt(suffixMatch[1], 10);
if (suffixIndex < lowestSuffixIndex) {
lowestSuffixIndex = suffixIndex;
primaryAgentId = agentId;
}
}
return primaryAgentId;
}
/**
* Creates a mapMethod for getMessagesForConversation that processes agent content.
* - Strips agentId/groupId metadata from all content
* - For multi-agent: filters to primary agent content only (no suffix or lowest suffix)
* - For parallel agents (addedConvo with groupId): filters each group to its primary agent
* - For handoffs (agentId without groupId): keeps all content from all agents
* - For multi-agent: applies agent labels to content
*
* The key distinction:
* - Parallel execution (addedConvo): Parts have both agentId AND groupId
* - Handoffs: Parts only have agentId, no groupId
*
* @param {Agent} primaryAgent - Primary agent configuration
* @param {Map<string, Agent>} [agentConfigs] - Additional agent configurations
* @returns {(message: TMessage) => TMessage} Map method for processing messages
@ -127,40 +157,38 @@ function createMultiAgentMapper(primaryAgent, agentConfigs) {
return message;
}
// Find primary agent ID (no suffix, or lowest suffix number) - only needed for multi-agent
let primaryAgentId = null;
let hasAgentMetadata = false;
if (hasMultipleAgents) {
let lowestSuffixIndex = Infinity;
for (const part of message.content) {
const agentId = part?.agentId;
if (!agentId) {
continue;
}
hasAgentMetadata = true;
const suffixMatch = agentId.match(AGENT_SUFFIX_PATTERN);
if (!suffixMatch) {
primaryAgentId = agentId;
break;
}
const suffixIndex = parseInt(suffixMatch[1], 10);
if (suffixIndex < lowestSuffixIndex) {
lowestSuffixIndex = suffixIndex;
primaryAgentId = agentId;
}
}
} else {
// Single agent: just check if any metadata exists
hasAgentMetadata = message.content.some((part) => part?.agentId || part?.groupId);
}
// Check for metadata
const hasAgentMetadata = message.content.some((part) => part?.agentId || part?.groupId != null);
if (!hasAgentMetadata) {
return message;
}
try {
// Build a map of groupId -> Set of agentIds, to find primary per group
/** @type {Map<number, Set<string>>} */
const groupAgentMap = new Map();
for (const part of message.content) {
const groupId = part?.groupId;
const agentId = part?.agentId;
if (groupId != null && agentId) {
if (!groupAgentMap.has(groupId)) {
groupAgentMap.set(groupId, new Set());
}
groupAgentMap.get(groupId).add(agentId);
}
}
// For each group, find the primary agent
/** @type {Map<number, string>} */
const groupPrimaryMap = new Map();
for (const [groupId, agentIds] of groupAgentMap) {
const primary = findPrimaryAgentId(agentIds);
if (primary) {
groupPrimaryMap.set(groupId, primary);
}
}
/** @type {Array<TMessageContentParts>} */
const filteredContent = [];
/** @type {Record<number, string>} */
@ -168,8 +196,16 @@ function createMultiAgentMapper(primaryAgent, agentConfigs) {
for (const part of message.content) {
const agentId = part?.agentId;
// For single agent: include all parts; for multi-agent: filter to primary
if (!hasMultipleAgents || !agentId || agentId === primaryAgentId) {
const groupId = part?.groupId;
// Filtering logic:
// - No groupId (handoffs): always include
// - Has groupId (parallel): only include if it's the primary for that group
const isParallelPart = groupId != null;
const groupPrimary = isParallelPart ? groupPrimaryMap.get(groupId) : null;
const shouldInclude = !isParallelPart || !agentId || agentId === groupPrimary;
if (shouldInclude) {
const newIndex = filteredContent.length;
const { agentId: _a, groupId: _g, ...cleanPart } = part;
filteredContent.push(cleanPart);
@ -325,11 +361,14 @@ class AgentClient extends BaseClient {
{ instructions = null, additional_instructions = null },
opts,
) {
const hasAddedConvo = this.options.req?.body?.addedConvo != null;
const orderedMessages = this.constructor.getMessagesForConversation({
messages,
parentMessageId,
summary: this.shouldSummarize,
mapMethod: createMultiAgentMapper(this.options.agent, this.agentConfigs),
mapMethod: hasAddedConvo
? createMultiAgentMapper(this.options.agent, this.agentConfigs)
: undefined,
});
let payload;

View file

@ -5,6 +5,8 @@ const {
validateAgentModel,
getCustomEndpointConfig,
createSequentialChainEdges,
createEdgeCollector,
filterOrphanedEdges,
} = require('@librechat/api');
const {
EModelEndpoint,
@ -143,10 +145,17 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
const agent_ids = primaryConfig.agent_ids;
let userMCPAuthMap = primaryConfig.userMCPAuthMap;
/** @type {Set<string>} Track agents that failed to load (orphaned references) */
const skippedAgentIds = new Set();
async function processAgent(agentId) {
const agent = await getAgent({ id: agentId });
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
logger.warn(
`[processAgent] Handoff agent ${agentId} not found, skipping (orphaned reference)`,
);
skippedAgentIds.add(agentId);
return null;
}
const validationResult = await validateAgentModel({
@ -187,37 +196,31 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
userMCPAuthMap = config.userMCPAuthMap;
}
agentConfigs.set(agentId, config);
return agent;
}
let edges = primaryConfig.edges;
const checkAgentInit = (agentId) => agentId === primaryConfig.id || agentConfigs.has(agentId);
if ((edges?.length ?? 0) > 0) {
for (const edge of edges) {
if (Array.isArray(edge.to)) {
for (const to of edge.to) {
if (checkAgentInit(to)) {
continue;
}
await processAgent(to);
}
} else if (typeof edge.to === 'string' && checkAgentInit(edge.to)) {
continue;
} else if (typeof edge.to === 'string') {
await processAgent(edge.to);
}
if (Array.isArray(edge.from)) {
for (const from of edge.from) {
if (checkAgentInit(from)) {
continue;
}
await processAgent(from);
}
} else if (typeof edge.from === 'string' && checkAgentInit(edge.from)) {
continue;
} else if (typeof edge.from === 'string') {
await processAgent(edge.from);
// Graph topology discovery for recursive agent handoffs (BFS)
const { edgeMap, agentsToProcess, collectEdges } = createEdgeCollector(
checkAgentInit,
skippedAgentIds,
);
// Seed with primary agent's edges
collectEdges(primaryConfig.edges);
// BFS to load and merge all connected agents (enables transitive handoffs: A->B->C)
while (agentsToProcess.size > 0) {
const agentId = agentsToProcess.values().next().value;
agentsToProcess.delete(agentId);
try {
const agent = await processAgent(agentId);
if (agent?.edges?.length) {
collectEdges(agent.edges);
}
} catch (err) {
logger.error(`[initializeClient] Error processing agent ${agentId}:`, err);
}
}
@ -229,11 +232,12 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
}
await processAgent(agentId);
}
const chain = await createSequentialChainEdges([primaryConfig.id].concat(agent_ids), '{convo}');
edges = edges ? edges.concat(chain) : chain;
collectEdges(chain);
}
let edges = Array.from(edgeMap.values());
/** Multi-Convo: Process addedConvo for parallel agent execution */
const { userMCPAuthMap: updatedMCPAuthMap } = await processAddedConvo({
req,
@ -261,6 +265,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
edges = [];
}
// Filter out edges referencing non-existent agents (orphaned references)
edges = filterOrphanedEdges(edges, skippedAgentIds);
primaryConfig.edges = edges;
let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint];

10
package-lock.json generated
View file

@ -59,7 +59,7 @@
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.0.61",
"@librechat/agents": "^3.0.66",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -12610,9 +12610,9 @@
}
},
"node_modules/@librechat/agents": {
"version": "3.0.61",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.61.tgz",
"integrity": "sha512-fmVC17G/RuLd38XG6/2olS49qd96SPcav9Idcb30Bv7gUZx/kOCqPay4GeMnwXDWXnDxTktNRCP5Amb0pEYuOw==",
"version": "3.0.66",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.66.tgz",
"integrity": "sha512-JpQo7w+/yLM3dJ46lyGrm4gPTjiHERwcpojw7drvpYWqOU4e2jmjK0JbNxQ0jP00q+nDhPG+mqJ2qQU7TVraOQ==",
"license": "MIT",
"dependencies": {
"@langchain/anthropic": "^0.3.26",
@ -43057,7 +43057,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",

View file

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

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

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

View file

@ -1,4 +1,5 @@
export * from './chain';
export * from './edges';
export * from './initialize';
export * from './legacy';
export * from './memory';

View file

@ -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[];
}

View file

@ -140,6 +140,7 @@ export async function createRun({
provider,
reasoningKey,
agentId: agent.id,
name: agent.name ?? undefined,
tools: agent.tools,
clientOptions: llmConfig,
instructions: systemContent,

View file

@ -125,5 +125,6 @@ const agentSchema = new Schema<IAgent>(
);
agentSchema.index({ updatedAt: -1, _id: 1 });
agentSchema.index({ 'edges.to': 1 });
export default agentSchema;