diff --git a/api/models/Agent.js b/api/models/Agent.js index fd71ef282c..3df2bcbec2 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -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; }; diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js index 2e3ecd0f5f..51256f8cf1 100644 --- a/api/models/Agent.spec.js +++ b/api/models/Agent.spec.js @@ -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(); diff --git a/api/package.json b/api/package.json index fd3ec61c5d..68bde06c5e 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 2f40dbe083..b29f974485 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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} 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} [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>} */ + 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} */ + const groupPrimaryMap = new Map(); + for (const [groupId, agentIds] of groupAgentMap) { + const primary = findPrimaryAgentId(agentIds); + if (primary) { + groupPrimaryMap.set(groupId, primary); + } + } + /** @type {Array} */ const filteredContent = []; /** @type {Record} */ @@ -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; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 8f7b62345f..626beed153 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -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} 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]; diff --git a/package-lock.json b/package-lock.json index eafc14f79b..8678ea73eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/api/package.json b/packages/api/package.json index e292882aeb..776f23d33a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/agents/edges.spec.ts b/packages/api/src/agents/edges.spec.ts new file mode 100644 index 0000000000..1b30a202d0 --- /dev/null +++ b/packages/api/src/agents/edges.spec.ts @@ -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(); + 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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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); + }); + }); +}); diff --git a/packages/api/src/agents/edges.ts b/packages/api/src/agents/edges.ts new file mode 100644 index 0000000000..4d2883d165 --- /dev/null +++ b/packages/api/src/agents/edges.ts @@ -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): 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; +} + +/** + * 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, +): { + edgeMap: Map; + agentsToProcess: Set; + collectEdges: (edgeList: GraphEdge[] | undefined) => void; +} { + const edgeMap = new Map(); + const agentsToProcess = new Set(); + + 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 }; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 44ef3e9de8..7f4be5f0ec 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -1,4 +1,5 @@ export * from './chain'; +export * from './edges'; export * from './initialize'; export * from './legacy'; export * from './memory'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index a37ddf4848..2671b8d65f 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -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(); 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[]; } diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index ae083253f9..6b18c73799 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -140,6 +140,7 @@ export async function createRun({ provider, reasoningKey, agentId: agent.id, + name: agent.name ?? undefined, tools: agent.tools, clientOptions: llmConfig, instructions: systemContent, diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index 4b10572afe..51739f552a 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -125,5 +125,6 @@ const agentSchema = new Schema( ); agentSchema.index({ updatedAt: -1, _id: 1 }); +agentSchema.index({ 'edges.to': 1 }); export default agentSchema;