diff --git a/packages/api/src/agents/discovery.spec.ts b/packages/api/src/agents/discovery.spec.ts index 40cb51d93b..5e57d547f5 100644 --- a/packages/api/src/agents/discovery.spec.ts +++ b/packages/api/src/agents/discovery.spec.ts @@ -264,6 +264,94 @@ describe('discoverConnectedAgents', () => { ); }); + it('prunes sub-agents disconnected from the primary after edge filtering', async () => { + // Graph: primary A has edges [A->B, B->C] stored directly on A. + // BFS loads B and C before permission is checked. B is skipped, and + // both edges reference B so they are filtered out. C was initialized + // but is now unreachable from A through any surviving edge — it must + // NOT be returned (otherwise it becomes a stray start node when + // createRun sees `agents.length > 1`). + const edges: GraphEdge[] = [ + { from: 'A', to: 'B', edgeType: 'handoff' }, + { from: 'B', to: 'C', edgeType: 'handoff' }, + ]; + const primaryConfig = makeConfig('A', edges); + + const agentMap: Record = { + B: makeAgent('B', []), + C: makeAgent('C', []), + }; + const getAgent = jest.fn(async ({ id }: { id: string }) => agentMap[id] ?? null); + // B fails permission, C passes. + const checkPermission = jest.fn( + async ({ resourceId }: { resourceId: unknown }) => resourceId !== 'mongo-B', + ); + + const result = await discoverConnectedAgents( + { + req: makeReq(), + res: makeRes(), + primaryConfig, + allowedProviders: new Set(), + modelsConfig: { openai: ['gpt-4o'] }, + loadTools: jest.fn(), + }, + { + getAgent, + checkPermission, + logViolation: jest.fn(), + db: {} as never, + }, + ); + + expect(result.skippedAgentIds.has('B')).toBe(true); + expect(result.edges).toHaveLength(0); + // C is unreachable once B's edges are filtered → must be pruned. + expect(result.agentConfigs.has('C')).toBe(false); + expect(result.agentConfigs.size).toBe(0); + }); + + it('keeps sub-agents that remain reachable via surviving edges', async () => { + // Graph: A -> [B, C]; B is skipped, C is kept. C must remain because + // A -> C survives filtering. + const edges: GraphEdge[] = [ + { from: 'A', to: 'B', edgeType: 'handoff' }, + { from: 'A', to: 'C', edgeType: 'handoff' }, + ]; + const primaryConfig = makeConfig('A', edges); + + const agentMap: Record = { + B: makeAgent('B', []), + C: makeAgent('C', []), + }; + const getAgent = jest.fn(async ({ id }: { id: string }) => agentMap[id] ?? null); + const checkPermission = jest.fn( + async ({ resourceId }: { resourceId: unknown }) => resourceId !== 'mongo-B', + ); + + const result = await discoverConnectedAgents( + { + req: makeReq(), + res: makeRes(), + primaryConfig, + allowedProviders: new Set(), + modelsConfig: { openai: ['gpt-4o'] }, + loadTools: jest.fn(), + }, + { + getAgent, + checkPermission, + logViolation: jest.fn(), + db: {} as never, + }, + ); + + expect(result.agentConfigs.has('C')).toBe(true); + expect(result.agentConfigs.has('B')).toBe(false); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].to).toBe('C'); + }); + it('drops edges whose `from` references a skipped agent (bidirectional graph)', async () => { // Orchestrator A with bidirectional handoffs A<->B. B is inaccessible, // so `A -> B` and `B -> A` must both be filtered — otherwise `B -> A` diff --git a/packages/api/src/agents/discovery.ts b/packages/api/src/agents/discovery.ts index b1dc9fcd97..c07c85c643 100644 --- a/packages/api/src/agents/discovery.ts +++ b/packages/api/src/agents/discovery.ts @@ -287,6 +287,40 @@ export async function discoverConnectedAgents( const edges = filterOrphanedEdges(Array.from(edgeMap.values()), skippedAgentIds); + /** + * Prune sub-agents that were initialized eagerly during BFS but whose + * connecting edges were later dropped by `filterOrphanedEdges` (e.g. an + * `A -> B -> C` chain where B is skipped, so both edges disappear but + * C had already been loaded from the primary's edge scan). Without this, + * those disconnected agents still flow into `createRun` — which flips + * into multi-agent mode whenever `agents.length > 1` and turns the + * stranded sub-agent into an unintended parallel start node. + */ + const reachable = new Set([primaryConfig.id]); + const frontier: string[] = [primaryConfig.id]; + while (frontier.length > 0) { + const current = frontier.pop() as string; + for (const edge of edges) { + const sources = Array.isArray(edge.from) ? edge.from : [edge.from]; + if (!sources.includes(current)) { + continue; + } + const dests = Array.isArray(edge.to) ? edge.to : [edge.to]; + for (const dest of dests) { + if (typeof dest === 'string' && !reachable.has(dest)) { + reachable.add(dest); + frontier.push(dest); + } + } + } + } + + for (const agentId of [...agentConfigs.keys()]) { + if (!reachable.has(agentId)) { + agentConfigs.delete(agentId); + } + } + return { agentConfigs, edges,