Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions dev/src/utils/agent_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
isFolderExists,
loadFileData,
removeFolder,
tryToFindFileRecursively,
tryToFindFolderRecursively,
} from './file_utils.js';
import {AdkLogger} from './logger.js';

Expand All @@ -33,6 +33,11 @@ const logger = new AdkLogger({label: 'AgentLoader', colorize: {all: true}});
*/
const JS_FILES_EXTENSIONS = ['.js', '.cjs', '.mjs', '.ts', '.mts', '.cts'];

/**
* How many ancestor directories to search for a project's `node_modules`.
*/
const MAX_NODE_MODULES_LOOKUP_LEVELS = 10;

/**
* Supported JS/TS file module types.
*/
Expand Down Expand Up @@ -640,21 +645,23 @@ async function linkProjectNodeModules(
}
}

/**
* Find the `node_modules` a bundled agent should resolve its externals from.
*
* Mirrors Node's own upward `node_modules` walk, so a project whose own
* directory has a `package.json` but no `node_modules` — the npm/pnpm
* workspace layout, where dependencies are hoisted to the workspace root —
* still resolves. Returns `undefined` when nothing is found within the bound.
*/
async function getProjectNodeModulesDir(
sourceDir: string,
): Promise<string | undefined> {
try {
const packageJsonPath = await tryToFindFileRecursively(
return await tryToFindFolderRecursively(
sourceDir,
'package.json',
10,
);
const nodeModulesDir = path.join(
path.dirname(packageJsonPath),
'node_modules',
MAX_NODE_MODULES_LOOKUP_LEVELS,
);

return (await isFolderExists(nodeModulesDir)) ? nodeModulesDir : undefined;
} catch {
return undefined;
}
Expand Down
70 changes: 55 additions & 15 deletions dev/src/utils/file_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,35 +116,75 @@ export function getTempDir(prefix?: string): string {
return path.join(...pathParts);
}

/**
* Try to find a file recursively in the given folder.
* @param sourceFolder The folder to search in.
* @param fileName The name of the file to find.
* @param maxIterations The maximum number of iterations to perform.
* @returns The absolute path of the found file.
* @throws Error if the file is not found after the maximum number of
* iterations.
*/
export async function tryToFindFileRecursively(
/** Walk `sourceFolder` and its ancestors looking for an entry named `entryName`. */
async function tryToFindEntryRecursively(
sourceFolder: string,
fileName: string,
entryName: string,
maxIterations: number,
exists: (entryPath: string) => Promise<boolean>,
): Promise<string> {
let currentFolder = sourceFolder;

for (let i = 0; i < maxIterations; i++) {
const filePath = path.join(currentFolder, fileName);
const entryPath = path.join(currentFolder, entryName);

if (await isFileExists(filePath)) {
return filePath;
if (await exists(entryPath)) {
return entryPath;
}

currentFolder = path.dirname(currentFolder);
}

throw new Error(
`No ${fileName} found in ${
`No ${entryName} found in ${
sourceFolder
} or its parent folders up to ${maxIterations} levels.`,
);
}

/**
* Try to find a file recursively in the given folder.
* @param sourceFolder The folder to search in.
* @param fileName The name of the file to find.
* @param maxIterations The maximum number of iterations to perform.
* @returns The absolute path of the found file.
* @throws Error if the file is not found after the maximum number of
* iterations.
*/
export async function tryToFindFileRecursively(
sourceFolder: string,
fileName: string,
maxIterations: number,
): Promise<string> {
return tryToFindEntryRecursively(
sourceFolder,
fileName,
maxIterations,
isFileExists,
);
}

/**
* Try to find a folder recursively in the given folder.
*
* Directories named `folderName` match; a plain file with the same name does
* not, and the walk continues into the parent folder.
* @param sourceFolder The folder to search in.
* @param folderName The name of the folder to find.
* @param maxIterations The maximum number of iterations to perform.
* @returns The absolute path of the found folder.
* @throws Error if the folder is not found after the maximum number of
* iterations.
*/
export async function tryToFindFolderRecursively(
sourceFolder: string,
folderName: string,
maxIterations: number,
): Promise<string> {
return tryToFindEntryRecursively(
sourceFolder,
folderName,
maxIterations,
isFolderExists,
);
}
54 changes: 51 additions & 3 deletions dev/test/utils/agent_loader_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ vi.mock('../../src/utils/file_utils.js', () => ({
isFileExists: vi.fn(),
isFolderExists: vi.fn(),
removeFolder: vi.fn(),
tryToFindFileRecursively: vi.fn(),
tryToFindFolderRecursively: vi.fn(),
}));

vi.mock('esbuild', async (importOriginal) => {
Expand Down Expand Up @@ -184,8 +184,8 @@ describe('AgentLoader', () => {
(fileUtils.removeFolder as Mock).mockImplementation((folderPath) =>
fs.rm(folderPath as string, {recursive: true, force: true}),
);
(fileUtils.tryToFindFileRecursively as Mock).mockImplementation(
async (_sourceFolder, fileName) => path.join(tempAgentsDir, fileName),
(fileUtils.tryToFindFolderRecursively as Mock).mockImplementation(
async (_sourceFolder, folderName) => path.join(tempAgentsDir, folderName),
);
});

Expand Down Expand Up @@ -309,6 +309,54 @@ describe('AgentLoader', () => {
await expect(fs.access(compiledAgentPath)).rejects.toThrow();
});

it('links an ancestor node_modules into the compiled output directory', async () => {
const nestedDir = path.join(tempAgentsDir, 'nested');
await fs.mkdir(nestedDir, {recursive: true});
const agentPath = path.join(nestedDir, 'agent1.js');
await fs.writeFile(agentPath, agent1JsContent);

const compiledAgentPath = compiledPath('agent1.cjs');
(esbuild.build as Mock).mockImplementation(async () => {
await fs.writeFile(compiledAgentPath, agent1JsContent);
return Promise.resolve();
});

const agentFile = new AgentFile(agentPath);
await agentFile.load();

await expect(fs.readlink(compiledPath('node_modules'))).resolves.toBe(
path.join(tempAgentsDir, 'node_modules'),
);
expect(fileUtils.tryToFindFolderRecursively).toHaveBeenCalledWith(
nestedDir,
'node_modules',
10,
);

await agentFile.dispose();
});

it('skips the node_modules link when no ancestor has one', async () => {
(fileUtils.tryToFindFolderRecursively as Mock).mockRejectedValue(
new Error('No node_modules found in /nowhere'),
);
const agentPath = path.join(tempAgentsDir, 'agent1.js');
await fs.writeFile(agentPath, agent1JsContent);

const compiledAgentPath = compiledPath('agent1.cjs');
(esbuild.build as Mock).mockImplementation(async () => {
await fs.writeFile(compiledAgentPath, agent1JsContent);
return Promise.resolve();
});

const agentFile = new AgentFile(agentPath);
await agentFile.load();

await expect(fs.access(compiledPath('node_modules'))).rejects.toThrow();

await agentFile.dispose();
});

it('throws when getting file path if agent is not loaded', () => {
const agentPath = path.join(tempAgentsDir, 'agent1.js');
const agentFile = new AgentFile(agentPath);
Expand Down
62 changes: 62 additions & 0 deletions dev/test/utils/file_utils_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
loadFileData,
saveToFile,
tryToFindFileRecursively,
tryToFindFolderRecursively,
} from '../../src/utils/file_utils.js';

vi.mock('node:fs/promises', async () => {
Expand Down Expand Up @@ -153,6 +154,67 @@ describe('file_utils', () => {
).rejects.toThrow(/No target.txt found/);
});

/** Makes `folderPaths` look like directories and everything else absent. */
function mockFoldersOnDisk(folderPaths: string[]) {
fsPromises.access.mockImplementation((p: string) =>
folderPaths.includes(p)
? Promise.resolve()
: Promise.reject(new Error('not found')),
);
fsPromises.stat.mockImplementation((p: string) =>
folderPaths.includes(p)
? Promise.resolve({isDirectory: () => true})
: Promise.reject(new Error('not found')),
);
}

it('tryToFindFolderRecursively finds a folder in the source folder itself', async () => {
const target = path.join('/a/b/c', 'node_modules');
mockFoldersOnDisk([target]);

await expect(
tryToFindFolderRecursively('/a/b/c', 'node_modules', 5),
).resolves.toBe(target);
});

it('tryToFindFolderRecursively finds a folder several levels up', async () => {
const target = path.join('/a', 'node_modules');
mockFoldersOnDisk([target]);

await expect(
tryToFindFolderRecursively('/a/b/c', 'node_modules', 5),
).resolves.toBe(target);
});

it('tryToFindFolderRecursively skips a same-named file and keeps walking up', async () => {
const decoyFile = path.join('/a/b/c', 'node_modules');
const target = path.join('/a', 'node_modules');
fsPromises.access.mockResolvedValue(undefined);
fsPromises.stat.mockImplementation((p: string) => {
if (p === decoyFile) {
return Promise.resolve({isDirectory: () => false});
}
if (p === target) {
return Promise.resolve({isDirectory: () => true});
}
return Promise.reject(new Error('not found'));
});

await expect(
tryToFindFolderRecursively('/a/b/c', 'node_modules', 5),
).resolves.toBe(target);
});

it('tryToFindFolderRecursively throws when folder not found within maxIterations', async () => {
mockFoldersOnDisk([]);

await expect(
tryToFindFolderRecursively('/a/b/c', 'node_modules', 2),
).rejects.toThrow(
'No node_modules found in /a/b/c or its parent folders up to 2 levels.',
);
});

it('listFiles returns entries', async () => {
const files = ['a.txt', 'b.txt'];
fsPromises.readdir.mockResolvedValue(files);
Expand Down
6 changes: 1 addition & 5 deletions tests/integration/agent_loader/__dirname/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@
"name": "dirname-test",
"version": "1.0.0",
"scripts": {
"start": "npx @google/adk-devtools run agent.ts"
},
"devDependencies": {
"@google/adk-devtools": "file:../../../../dev",
"@google/adk": "file:../../../../core"
"start": "node ../../../../dev/dist/esm/cli_entrypoint.js run agent.ts"
}
}
6 changes: 1 addition & 5 deletions tests/integration/agent_loader/__filename/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@
"name": "filename-test",
"version": "1.0.0",
"scripts": {
"start": "npx @google/adk-devtools run agent.ts"
},
"devDependencies": {
"@google/adk-devtools": "file:../../../../dev",
"@google/adk": "file:../../../../core"
"start": "node ../../../../dev/dist/esm/cli_entrypoint.js run agent.ts"
}
}
24 changes: 5 additions & 19 deletions tests/integration/agent_loader/agent_dirname_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {exec, spawn} from 'node:child_process';
import * as fs from 'node:fs/promises';
import {spawn} from 'node:child_process';
import * as path from 'node:path';
import {promisify} from 'node:util';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
import {beforeAll, describe, expect, it} from 'vitest';
import {sendInput} from '../test_case_utils.js';
import {assertWorkspaceAdkCliAvailable} from '../workspace_cli.js';

const execAsync = promisify(exec);
const dirname = process.cwd();
const TEST_EXECUTION_TIMEOUT = 40000;

Expand All @@ -25,8 +23,8 @@ describe.each(['__dirname', '__filename', 'import_meta_url'])(
);

beforeAll(async () => {
await execAsync('npm install', {cwd: projectPath});
}, TEST_EXECUTION_TIMEOUT);
await assertWorkspaceAdkCliAvailable();
});

it(
'should run agent and load params from file nearby via package.json script',
Expand All @@ -45,17 +43,5 @@ describe.each(['__dirname', '__filename', 'import_meta_url'])(
},
TEST_EXECUTION_TIMEOUT,
);

afterAll(async () => {
await fs
.rm(path.join(projectPath, 'node_modules'), {
recursive: true,
force: true,
})
.catch(() => {});
await fs
.unlink(path.join(projectPath, 'package-lock.json'))
.catch(() => {});
}, TEST_EXECUTION_TIMEOUT);
},
);
6 changes: 1 addition & 5 deletions tests/integration/agent_loader/import_meta_url/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "npx @google/adk-devtools run agent.ts"
},
"devDependencies": {
"@google/adk-devtools": "file:../../../../dev",
"@google/adk": "file:../../../../core"
"start": "node ../../../../dev/dist/esm/cli_entrypoint.js run agent.ts"
}
}
6 changes: 1 addition & 5 deletions tests/integration/app_loader/app_default/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@
"name": "app-default-test",
"version": "1.0.0",
"scripts": {
"start": "npx @google/adk-devtools run app.ts"
},
"devDependencies": {
"@google/adk-devtools": "file:../../../../dev",
"@google/adk": "file:../../../../core"
"start": "node ../../../../dev/dist/esm/cli_entrypoint.js run app.ts"
}
}
Loading
Loading