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 @@ -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;

Expand Down Expand Up @@ -1036,9 +1036,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
7 changes: 7 additions & 0 deletions dev/src/utils/agent_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentFile> {
await this.preloadAgents();

Expand Down
106 changes: 97 additions & 9 deletions 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 Down Expand Up @@ -213,26 +214,40 @@ const TEST_AGENT = new TestAgent({
],
});

/** Test double for the `AgentFile` the loader lends out. */
interface AgentFileStub {
disposeCount: number;
load: () => Promise<BaseAgent>;
[Symbol.asyncDispose]: () => Promise<void>;
}

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;
let server: AdkApiServer;
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<void> {
return;
},
}),
getAgentFile: () => Promise.resolve(agentFile),
} as unknown as AgentLoader;
sessionService = new InMemorySessionService();
memoryService = new InMemoryMemoryService();
Expand Down Expand Up @@ -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<void> {
const response = await client.post<Event[]>('/run', RUN_BODY);

expect(response.status).toBe(200);
}

async function getGraph(times: number): Promise<void> {
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;
Expand Down
18 changes: 18 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,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();
Expand Down
Loading