diff --git a/dev/src/server/adk_api_server.ts b/dev/src/server/adk_api_server.ts index a52ad63ab..08545bda6 100644 --- a/dev/src/server/adk_api_server.ts +++ b/dev/src/server/adk_api_server.ts @@ -336,7 +336,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; @@ -1036,9 +1036,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..21d87cfe2 100644 --- a/dev/src/utils/agent_loader.ts +++ b/dev/src/utils/agent_loader.ts @@ -458,6 +458,13 @@ export class AgentLoader { return appNames.sort(); } + /** + * Lends the caller the `AgentFile` this loader owns for `agentName`. + * + * Every caller shares the handle, and only `invalidateAll()` or + * `disposeAll()` ends its life. A caller must not dispose it: disposal + * deletes the compiled artifact that the other callers still read. + */ async getAgentFile(agentName: string): Promise { await this.preloadAgents(); diff --git a/dev/test/server/adk_api_server_test.ts b/dev/test/server/adk_api_server_test.ts index d6db712d2..f2631f2fb 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, @@ -213,8 +214,29 @@ const TEST_AGENT = new TestAgent({ ], }); +/** Test double for the `AgentFile` the loader lends out. */ +interface AgentFileStub { + disposeCount: number; + load: () => Promise; + [Symbol.asyncDispose]: () => Promise; +} + +function createAgentFileStub(): AgentFileStub { + const stub: AgentFileStub = { + disposeCount: 0, + load: () => Promise.resolve(TEST_AGENT), + [Symbol.asyncDispose]: () => { + stub.disposeCount++; + return Promise.resolve(); + }, + }; + + return stub; +} + describe('AdkWebServer', () => { let agentLoader: AgentLoader; + let agentFile: AgentFileStub; let sessionService: BaseSessionService; let memoryService: BaseMemoryService; let artifactService: BaseArtifactService; @@ -222,17 +244,10 @@ describe('AdkWebServer', () => { let client: HttpClient; beforeEach(async () => { + agentFile = createAgentFileStub(); agentLoader = { listAgents: () => Promise.resolve(['testApp']), - getAgentFile: () => - Promise.resolve({ - load() { - return Promise.resolve(TEST_AGENT); - }, - async [Symbol.asyncDispose](): Promise { - return; - }, - }), + getAgentFile: () => Promise.resolve(agentFile), } as unknown as AgentLoader; sessionService = new InMemorySessionService(); memoryService = new InMemoryMemoryService(); @@ -943,6 +958,79 @@ describe('AdkWebServer', () => { }); }); + describe('agent file lifecycle', () => { + const RUN_BODY = { + appName: 'testApp', + userId: 'testUser', + sessionId: 'sessionId', + newMessage: {parts: [{text: 'Hello test agent!'}], role: 'user'}, + }; + const GRAPH_PATH = + '/apps/testApp/users/testUser/sessions/fullSession/events/event1/graph'; + + async function postRun(): Promise { + const response = await client.post('/run', RUN_BODY); + + expect(response.status).toBe(200); + } + + async function getGraph(times: number): Promise { + const originalGetSession = sessionService.getSession; + sessionService.getSession = async () => + createSession({ + id: 'fullSession', + appName: 'testApp', + userId: 'testUser', + events: [ + createEvent({ + id: 'event1', + author: 'model', + content: {parts: [{functionCall: {name: 'foo', args: {}}}]}, + invocationId: 'inv-1', + }), + ], + }); + + try { + for (let i = 0; i < times; i++) { + const response = await client.get<{dotSrc: string}>(GRAPH_PATH); + + expect(response.status).toBe(200); + } + } finally { + sessionService.getSession = originalGetSession; + } + } + + beforeEach(async () => { + await sessionService.createSession({ + appName: 'testApp', + userId: 'testUser', + sessionId: 'sessionId', + }); + }); + + it('leaves the loader-owned agent file usable after two sequential run requests', async () => { + await postRun(); + await postRun(); + + expect(agentFile.disposeCount).toBe(0); + }); + + it('leaves the loader-owned agent file usable after two sequential agent-graph requests', async () => { + await getGraph(2); + + expect(agentFile.disposeCount).toBe(0); + }); + + it('does not dispose the loader-owned agent file when a run and a graph request share an app', async () => { + await postRun(); + await getGraph(1); + + 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..b65835afb 100644 --- a/dev/test/utils/agent_loader_test.ts +++ b/dev/test/utils/agent_loader_test.ts @@ -717,6 +717,24 @@ describe('AgentLoader', () => { await agentLoader.disposeAll(); }); + it('returns the same shared AgentFile instance for 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 first.dispose(); + const afterDispose = await loader.getAgentFile('agent2'); + + expect(() => afterDispose.getFilePath()).toThrow( + 'Agent is disposed and can not be used', + ); + + await loader.disposeAll(); + }); + it('disposes all agent files', async () => { const agentLoader = new AgentLoader(tempAgentsDir); await agentLoader.listAgents();