diff --git a/dev/src/server/adk_api_server.ts b/dev/src/server/adk_api_server.ts index dd74b1290..5c756a760 100644 --- a/dev/src/server/adk_api_server.ts +++ b/dev/src/server/adk_api_server.ts @@ -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; @@ -1046,9 +1046,7 @@ export class AdkApiServer { runConfig?: RunConfig; abortSignal: AbortSignal; }): AsyncGenerator { - 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); diff --git a/dev/src/utils/agent_loader.ts b/dev/src/utils/agent_loader.ts index d5097c482..674f9d785 100644 --- a/dev/src/utils/agent_loader.ts +++ b/dev/src/utils/agent_loader.ts @@ -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 { @@ -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 { await this.preloadAgents(); @@ -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 { 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() { diff --git a/dev/test/server/adk_api_server_test.ts b/dev/test/server/adk_api_server_test.ts index 6f464992e..81fab11c1 100644 --- a/dev/test/server/adk_api_server_test.ts +++ b/dev/test/server/adk_api_server_test.ts @@ -6,6 +6,7 @@ import {AGENT_CARD_PATH, AgentCard} from '@a2a-js/sdk'; import { + BaseAgent, BaseArtifactService, BaseMemoryService, BaseSessionService, @@ -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; @@ -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 { + return Promise.resolve(TEST_AGENT); + } + + override dispose(): Promise { + 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 { + const response = await client.post('/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 { + 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; @@ -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; diff --git a/dev/test/utils/agent_loader_test.ts b/dev/test/utils/agent_loader_test.ts index 2285df895..3313e1c4c 100644 --- a/dev/test/utils/agent_loader_test.ts +++ b/dev/test/utils/agent_loader_test.ts @@ -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();