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
6 changes: 2 additions & 4 deletions dev/src/server/adk_api_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ export class AdkApiServer {

const functionCalls = getFunctionCalls(event);
const functionResponses = getFunctionResponses(event);
await using agentFile = await this.agentLoader.getAgentFile(appName);
const agentFile = await this.agentLoader.getAgentFile(appName);
const loaded = await agentFile.load();
const rootAgent = isApp(loaded) ? loaded.rootAgent : loaded;

Expand Down Expand Up @@ -1046,9 +1046,7 @@ export class AdkApiServer {
runConfig?: RunConfig;
abortSignal: AbortSignal;
}): AsyncGenerator<Event> {
await using agentFile = await this.agentLoader.getAgentFile(
options.appName,
);
const agentFile = await this.agentLoader.getAgentFile(options.appName);
const loaded = await agentFile.load();
const runner = await this.getRunner(loaded, options.appName);

Expand Down
38 changes: 30 additions & 8 deletions dev/src/utils/agent_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,18 +420,29 @@ export class AgentLoader {
}

/**
* Disposes all cached agents and marks them for reload on the next request.
* Empties the cache and hands back the `AgentFile`s it held, so the caller
* can end their lives. The next `getAgentFile()` re-scans the agents
* directory.
*/
private invalidateAll(): void {
for (const agentFile of Object.values(this.preloadedAgents)) {
agentFile.dispose().catch(() => {});
}
private takeAgentFiles(): AgentFile[] {
const agentFiles = Object.values(this.preloadedAgents);

for (const key of Object.keys(this.preloadedAgents)) {
delete this.preloadedAgents[key];
}

this.agentsAlreadyPreloaded = false;

return agentFiles;
}

/**
* Disposes all cached agents and marks them for reload on the next request.
*/
private invalidateAll(): void {
for (const agentFile of this.takeAgentFiles()) {
agentFile.dispose().catch(() => {});
}
}

async listAgents(): Promise<string[]> {
Expand All @@ -458,6 +469,14 @@ export class AgentLoader {
return appNames.sort();
}

/**
* Lends the caller the `AgentFile` this loader owns for `agentName`.
*
* Every caller shares one handle and must not dispose it: disposal deletes
* the compiled artifact and the temp directory the other callers still read
* from. Only `invalidateAll()` and `disposeAll()` end a handle's life, and
* both drop the cache entry with it.
*/
async getAgentFile(agentName: string): Promise<AgentFile> {
await this.preloadAgents();

Expand All @@ -468,12 +487,15 @@ export class AgentLoader {
return this.getAgentFile(appName);
}

/**
* Disposes every `AgentFile` this loader owns and empties its cache, so a
* later `getAgentFile()` re-scans instead of lending a disposed handle.
*/
async disposeAll(): Promise<void> {
this.watcher?.close();
this.watcher = undefined;
await Promise.all(
Object.values(this.preloadedAgents).map((f) => f.dispose()),
);

await Promise.all(this.takeAgentFiles().map((f) => f.dispose()));
}

async preloadAgents() {
Expand Down
113 changes: 112 additions & 1 deletion dev/test/server/adk_api_server_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import {AGENT_CARD_PATH, AgentCard} from '@a2a-js/sdk';
import {
BaseAgent,
BaseArtifactService,
BaseMemoryService,
BaseSessionService,
Expand All @@ -29,7 +30,7 @@ import {
A2A_AUTH_TOKEN_ENV_VAR,
AdkApiServer,
} from '../../src/server/adk_api_server.js';
import {AgentLoader} from '../../src/utils/agent_loader.js';
import {AgentFile, AgentLoader} from '../../src/utils/agent_loader.js';

interface JsonRpcResponse {
result?: unknown;
Expand Down Expand Up @@ -213,6 +214,74 @@ const TEST_AGENT = new TestAgent({
],
});

const GRAPH_PATH =
'/apps/testApp/users/testUser/sessions/fullSession/events/event1/graph';

/** An `AgentFile` that lends `TEST_AGENT` and counts how often it is disposed. */
class CountingAgentFile extends AgentFile {
disposeCount = 0;

constructor() {
super('testApp.ts');
}

override load(): Promise<BaseAgent> {
return Promise.resolve(TEST_AGENT);
}

override dispose(): Promise<void> {
this.disposeCount++;

return Promise.resolve();
}
}

/** A session holding one event with a function call, for the graph endpoint. */
function createGraphSession(): Session {
return createSession({
id: 'fullSession',
appName: 'testApp',
userId: 'testUser',
events: [
createEvent({
id: 'event1',
author: 'model',
content: {parts: [{functionCall: {name: 'foo', args: {}}}]},
invocationId: 'inv-1',
}),
],
});
}

/** Runs the test agent once and reports the HTTP status. */
async function postRun(client: HttpClient): Promise<number> {
const response = await client.post<Event[]>('/run', {
appName: 'testApp',
userId: 'testUser',
sessionId: 'sessionId',
newMessage: {parts: [{text: 'Hello test agent!'}], role: 'user'},
});

return response.status;
}

/** Fetches the event graph once and reports the HTTP status. */
async function getEventGraph(
client: HttpClient,
sessionService: BaseSessionService,
): Promise<number> {
const originalGetSession = sessionService.getSession;
sessionService.getSession = () => Promise.resolve(createGraphSession());

try {
const response = await client.get<{dotSrc: string}>(GRAPH_PATH);

return response.status;
} finally {
sessionService.getSession = originalGetSession;
}
}

describe('AdkWebServer', () => {
let agentLoader: AgentLoader;
let sessionService: BaseSessionService;
Expand Down Expand Up @@ -943,6 +1012,48 @@ describe('AdkWebServer', () => {
});
});

describe('agent file lifecycle', () => {
let agentFile: CountingAgentFile;
let originalGetAgentFile: AgentLoader['getAgentFile'];

beforeEach(async () => {
agentFile = new CountingAgentFile();
originalGetAgentFile = agentLoader.getAgentFile;
agentLoader.getAgentFile = () => Promise.resolve(agentFile);

await sessionService.createSession({
appName: 'testApp',
userId: 'testUser',
sessionId: 'sessionId',
});
});

afterEach(() => {
agentLoader.getAgentFile = originalGetAgentFile;
});

it('leaves the borrowed agent file undisposed across two sequential run requests', async () => {
expect(await postRun(client)).toBe(200);
expect(await postRun(client)).toBe(200);

expect(agentFile.disposeCount).toBe(0);
});

it('leaves the borrowed agent file undisposed across two sequential agent-graph requests', async () => {
expect(await getEventGraph(client, sessionService)).toBe(200);
expect(await getEventGraph(client, sessionService)).toBe(200);

expect(agentFile.disposeCount).toBe(0);
});

it('does not dispose the borrowed agent file when a run and a graph request share an app', async () => {
expect(await postRun(client)).toBe(200);
expect(await getEventGraph(client, sessionService)).toBe(200);

expect(agentFile.disposeCount).toBe(0);
});
});

describe('A2A', () => {
const A2A_TOKEN = 'test-a2a-token';
let a2aServer: AdkApiServer | undefined;
Expand Down
32 changes: 32 additions & 0 deletions dev/test/utils/agent_loader_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,38 @@ describe('AgentLoader', () => {
await agentLoader.disposeAll();
});

it('lends the same live AgentFile to repeated getAgentFile calls', async () => {
const loader = new AgentLoader(tempAgentsDir);
const first = await loader.getAgentFile('agent2');
const second = await loader.getAgentFile('agent2');

expect(second).toBe(first);

await first.load();
await expect(fs.access(first.getFilePath())).resolves.toBeUndefined();

await loader.disposeAll();
});

it('re-scans and lends a fresh AgentFile after disposeAll', async () => {
const loader = new AgentLoader(tempAgentsDir);
await loader.listAgents();
const before = await loader.getAgentFile('agent2');
await before.load();
const beforePath = before.getFilePath();

await loader.disposeAll();
await expect(fs.access(beforePath)).rejects.toThrow();

const after = await loader.getAgentFile('agent2');

expect(after).not.toBe(before);
await expect(after.load()).resolves.toBeDefined();
await expect(fs.access(after.getFilePath())).resolves.toBeUndefined();

await loader.disposeAll();
});

it('disposes all agent files', async () => {
const agentLoader = new AgentLoader(tempAgentsDir);
await agentLoader.listAgents();
Expand Down
Loading