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
21 changes: 20 additions & 1 deletion dev/src/server/adk_api_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import express, {Request, Response} from 'express';
import * as http from 'node:http';
import * as path from 'node:path';

import {AgentFileOptions, AgentLoader} from '../utils/agent_loader.js';
import {AgentFileOptions, AgentLoader, AppInfo} from '../utils/agent_loader.js';
import {AdkLogger} from '../utils/logger.js';
import {
ApiServerSpanExporter,
Expand All @@ -50,6 +50,11 @@ import {getAgentGraphAsDot} from './agent_graph.js';
*/
export const A2A_AUTH_TOKEN_ENV_VAR = 'ADK_A2A_AUTH_TOKEN';

/** Response body of `GET /list-apps?detailed=true`. */
export interface ListAppsResponse {
apps: AppInfo[];
}

interface ServerOptions {
agentsDir?: string;
host?: string;
Expand Down Expand Up @@ -243,6 +248,20 @@ export class AdkApiServer {

app.get('/list-apps', async (req: Request, res: Response) => {
try {
// Anything other than these two values falls back to the legacy array
// response, so an unrecognised value never changes the response shape.
const detailedParam = req.query['detailed'];
const detailed = detailedParam === 'true' || detailedParam === '1';

if (detailed) {
const response: ListAppsResponse = {
apps: await this.agentLoader.listAgentsDetailed(),
};
res.json(response);

return;
}

const apps = await this.agentLoader.listAgents();
res.json(apps);
} catch (e: unknown) {
Expand Down
49 changes: 49 additions & 0 deletions dev/src/utils/agent_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ export interface AgentFileOptions {
moduleType?: FileModuleType;
}

/**
* Metadata for a single app, returned by the detailed form of `/list-apps`.
*
* The field names are camelCase to match the JSON that the Python dev server
* emits (`AppInfo` in adk-python `src/google/adk/cli/api_server.py`), because
* the same `adk-web` front-end bundle consumes both servers.
*/
export interface AppInfo {
/** Directory or file name that the agent was discovered under. */
name: string;
/** `name` of the loaded root agent, which need not match `name`. */
rootAgentName: string;
description: string;
language: 'typescript';
isComputerUse: boolean;
}

/**
* Default options for loading an agent file.
*
Expand Down Expand Up @@ -458,6 +475,38 @@ export class AgentLoader {
return appNames.sort();
}

/**
* Lists every discovered agent with its metadata, sorted by name.
*
* An agent that fails to load is logged and omitted, so one broken agent
* cannot fail the whole listing.
*/
async listAgentsDetailed(): Promise<AppInfo[]> {
const names = await this.listAgents();

const appsInfo: AppInfo[] = [];
for (const name of names) {
try {
const loaded = await this.preloadedAgents[name].load();
const agent = isApp(loaded) ? loaded.rootAgent : loaded;

appsInfo.push({
name,
rootAgentName: agent.name,
description: agent.description ?? '',
language: 'typescript',
// adk-js has no computer-use toolset to detect, so this is always
// false. The field stays for parity with the Python dev server.
isComputerUse: false,
});
} catch (e: unknown) {
logger.error(`Failed to load agent '${name}': ${e}`);
}
}

return appsInfo;
}

async getAgentFile(agentName: string): Promise<AgentFile> {
await this.preloadAgents();

Expand Down
69 changes: 69 additions & 0 deletions dev/test/server/adk_api_server_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {z} from 'zod';
import {
A2A_AUTH_TOKEN_ENV_VAR,
AdkApiServer,
ListAppsResponse,
} from '../../src/server/adk_api_server.js';
import {AgentLoader} from '../../src/utils/agent_loader.js';

Expand Down Expand Up @@ -224,6 +225,16 @@ describe('AdkWebServer', () => {
beforeEach(async () => {
agentLoader = {
listAgents: () => Promise.resolve(['testApp']),
listAgentsDetailed: () =>
Promise.resolve([
{
name: 'testApp',
rootAgentName: 'testAgent',
description: 'test agent',
language: 'typescript',
isComputerUse: false,
},
]),
getAgentFile: () =>
Promise.resolve({
load() {
Expand Down Expand Up @@ -813,6 +824,64 @@ describe('AdkWebServer', () => {
agentLoader.listAgents = originalListAgents;
}
});

const detailedEnvelope: ListAppsResponse = {
apps: [
{
name: 'testApp',
rootAgentName: 'testAgent',
description: 'test agent',
language: 'typescript',
isComputerUse: false,
},
],
};

it('returns detailed app info when detailed=true', async () => {
const response = await client.get<ListAppsResponse>(
'/list-apps?detailed=true',
);

expect(response.status).toBe(200);
expect(response.data).toEqual(detailedEnvelope);
});

it('accepts detailed=1', async () => {
const response = await client.get<ListAppsResponse>(
'/list-apps?detailed=1',
);

expect(response.status).toBe(200);
expect(response.data).toEqual(detailedEnvelope);
});

it('returns the plain array when detailed=false', async () => {
const response = await client.get<string[]>('/list-apps?detailed=false');

expect(response.status).toBe(200);
expect(response.data).toEqual(['testApp']);
});

it('returns the plain array for an unrecognised detailed value', async () => {
const response = await client.get<string[]>('/list-apps?detailed=yes');

expect(response.status).toBe(200);
expect(response.data).toEqual(['testApp']);
});

it('returns 500 if listAgentsDetailed fails', async () => {
const originalListAgentsDetailed = agentLoader.listAgentsDetailed;
agentLoader.listAgentsDetailed = () =>
Promise.reject(new Error('List failed'));

try {
await expect(
client.get('/list-apps?detailed=true'),
).rejects.toMatchObject({response: {status: 500}});
} finally {
agentLoader.listAgentsDetailed = originalListAgentsDetailed;
}
});
});

describe('Debug UI', () => {
Expand Down
90 changes: 90 additions & 0 deletions dev/test/utils/agent_loader_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
replaceDirnamePlugin,
} from '../../src/utils/agent_loader.js';
import * as fileUtils from '../../src/utils/file_utils.js';
import {AdkLogger} from '../../src/utils/logger.js';

vi.mock('../../src/utils/file_utils.js', () => ({
createTempDir: vi.fn(),
Expand Down Expand Up @@ -139,6 +140,17 @@ const agent = new FakeAgentForApp('agent_for_app_default');
export default new App({ name: 'test_app_default', rootAgent: agent });
`;

const agentWithDescriptionContent = `
const {BaseAgent} = require('@google/adk');

class FakeDescribedAgent extends BaseAgent {
constructor(name, description) {
super({ name, description });
}
}
exports.rootAgent = new FakeDescribedAgent('described_root', 'A described agent');
`;

describe('AgentLoader', () => {
let tempAgentsDir: string;
let tempLoaderDir: string;
Expand Down Expand Up @@ -788,6 +800,84 @@ describe('AgentLoader', () => {
await loader.disposeAll();
});

it('lists agents with detailed metadata', async () => {
await fs.writeFile(
path.join(tempAgentsDir, 'described_agent.js'),
agentWithDescriptionContent,
);

const loader = new AgentLoader(tempAgentsDir);
const result = await loader.listAgentsDetailed();

expect(result).toContainEqual({
name: 'described_agent',
rootAgentName: 'described_root',
description: 'A described agent',
language: 'typescript',
isComputerUse: false,
});

await loader.disposeAll();
});

it('defaults description to an empty string', async () => {
const loader = new AgentLoader(tempAgentsDir);
const result = await loader.listAgentsDetailed();

expect(result.find((a) => a.name === 'agent1')?.description).toBe('');

await loader.disposeAll();
});

it('sorts detailed entries by name', async () => {
const loader = new AgentLoader(tempAgentsDir);
const result = await loader.listAgentsDetailed();

expect(result.map((a) => a.name)).toEqual(['agent1', 'agent2', 'agent3']);

await loader.disposeAll();
});

it('unwraps the root agent of an App', async () => {
const appDir = path.join(tempAgentsDir, 'my_service');
await fs.mkdir(appDir, {recursive: true});
await fs.writeFile(path.join(appDir, 'app.js'), appJsContent);

const loader = new AgentLoader(tempAgentsDir);
const result = await loader.listAgentsDetailed();

expect(result.find((a) => a.name === 'my_service')?.rootAgentName).toBe(
'agent_for_app',
);

await loader.disposeAll();
});

it('skips and logs agents that fail to load', async () => {
const loader = new AgentLoader(tempAgentsDir);
await loader.preloadAgents();

const agent2File = await loader.getAgentFile('agent2');
const loadSpy = vi
.spyOn(agent2File, 'load')
.mockRejectedValue(new Error('boom'));
const errorSpy = vi
.spyOn(AdkLogger.prototype, 'error')
.mockImplementation(() => {});

try {
const result = await loader.listAgentsDetailed();

expect(result.map((a) => a.name)).toEqual(['agent1', 'agent3']);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(String(errorSpy.mock.calls[0][0])).toContain('agent2');
} finally {
loadSpy.mockRestore();
errorSpy.mockRestore();
await loader.disposeAll();
}
});

it('resets preload cache when invalidateAll is called (simulates file-change reload)', async () => {
const loader = new AgentLoader(tempAgentsDir);

Expand Down
Loading