From 02a36afc389231b2efa019efc628b4e515bfeb59 Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:27:16 -0500 Subject: [PATCH] feat: Add A2A Client Manager and tests (#15485) --- package-lock.json | 65 ++-- packages/a2a-server/package.json | 2 +- packages/core/package.json | 1 + .../src/agents/a2a-client-manager.test.ts | 305 ++++++++++++++++++ .../core/src/agents/a2a-client-manager.ts | 209 ++++++++++++ 5 files changed, 561 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/agents/a2a-client-manager.test.ts create mode 100644 packages/core/src/agents/a2a-client-manager.ts diff --git a/package-lock.json b/package-lock.json index c756b1a6d4..920a1a9d10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2500,6 +2500,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2680,6 +2681,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2713,6 +2715,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3081,6 +3084,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3114,6 +3118,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3166,6 +3171,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4396,6 +4402,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4673,6 +4680,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5684,6 +5692,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6129,8 +6138,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -7420,7 +7428,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8744,6 +8751,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -9346,7 +9354,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -9356,7 +9363,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9366,7 +9372,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9620,7 +9625,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9639,7 +9643,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9648,15 +9651,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -10942,6 +10943,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.6.tgz", "integrity": "sha512-QHl6l1cl3zPCaRMzt9TUbTX6Q5SzvkGEZDDad0DmSf5SPmT1/90k6pGPejEvDCJprkitwObXpPaTWGHItqsy4g==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -14136,8 +14138,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -14718,6 +14719,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14728,6 +14730,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16987,6 +16990,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17213,7 +17217,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -17221,6 +17226,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17405,6 +17411,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17567,7 +17574,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -17623,6 +17629,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17739,6 +17746,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17752,6 +17760,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18458,6 +18467,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18481,7 +18491,7 @@ "express": "^5.1.0", "fs-extra": "^11.3.0", "tar": "^7.5.2", - "uuid": "^11.1.0", + "uuid": "^13.0.0", "winston": "^3.17.0" }, "bin": { @@ -18769,16 +18779,16 @@ } }, "packages/a2a-server/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "packages/cli": { @@ -18932,6 +18942,7 @@ "strip-ansi": "^7.1.0", "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", + "uuid": "^13.0.0", "web-tree-sitter": "^0.25.10", "zod": "^3.25.76" }, @@ -19017,6 +19028,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19024,6 +19036,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "packages/core/node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", "version": "0.21.0-nightly.20251220.41a1a3eed", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index f7f2e35176..37dbd072aa 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -31,7 +31,7 @@ "express": "^5.1.0", "fs-extra": "^11.3.0", "tar": "^7.5.2", - "uuid": "^11.1.0", + "uuid": "^13.0.0", "winston": "^3.17.0" }, "devDependencies": { diff --git a/packages/core/package.json b/packages/core/package.json index 91576ba3b3..062e5ba5c6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -64,6 +64,7 @@ "strip-ansi": "^7.1.0", "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", + "uuid": "^13.0.0", "web-tree-sitter": "^0.25.10", "zod": "^3.25.76" }, diff --git a/packages/core/src/agents/a2a-client-manager.test.ts b/packages/core/src/agents/a2a-client-manager.test.ts new file mode 100644 index 0000000000..1fe55a42ba --- /dev/null +++ b/packages/core/src/agents/a2a-client-manager.test.ts @@ -0,0 +1,305 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + A2AClientManager, + type SendMessageResult, +} from './a2a-client-manager.js'; +import type { AgentCard, Task } from '@a2a-js/sdk'; +import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client'; +import { ClientFactory, DefaultAgentCardResolver } from '@a2a-js/sdk/client'; +import { debugLogger } from '../utils/debugLogger.js'; +import { + createAuthenticatingFetchWithRetry, + ClientFactoryOptions, +} from '@a2a-js/sdk/client'; + +vi.mock('../utils/debugLogger.js', () => ({ + debugLogger: { + debug: vi.fn(), + }, +})); + +vi.mock('@a2a-js/sdk/client', () => { + const ClientFactory = vi.fn(); + const DefaultAgentCardResolver = vi.fn(); + const RestTransportFactory = vi.fn(); + const JsonRpcTransportFactory = vi.fn(); + const ClientFactoryOptions = { + default: {}, + createFrom: vi.fn(), + }; + const createAuthenticatingFetchWithRetry = vi.fn(); + + DefaultAgentCardResolver.prototype.resolve = vi.fn(); + ClientFactory.prototype.createFromUrl = vi.fn(); + + return { + ClientFactory, + ClientFactoryOptions, + DefaultAgentCardResolver, + RestTransportFactory, + JsonRpcTransportFactory, + createAuthenticatingFetchWithRetry, + }; +}); + +describe('A2AClientManager', () => { + let manager: A2AClientManager; + + // Stable mocks initialized once + const sendMessageMock = vi.fn(); + const getTaskMock = vi.fn(); + const cancelTaskMock = vi.fn(); + const getAgentCardMock = vi.fn(); + const authFetchMock = vi.fn(); + + const mockClient = { + sendMessage: sendMessageMock, + getTask: getTaskMock, + cancelTask: cancelTaskMock, + getAgentCard: getAgentCardMock, + } as unknown as Client; + + const mockAgentCard: Partial = { name: 'TestAgent' }; + + beforeEach(() => { + vi.clearAllMocks(); + A2AClientManager.resetInstanceForTesting(); + manager = A2AClientManager.getInstance(); + + // Default mock implementations + getAgentCardMock.mockResolvedValue({ + ...mockAgentCard, + url: 'http://test.agent/real/endpoint', + } as AgentCard); + + vi.mocked(ClientFactory.prototype.createFromUrl).mockResolvedValue( + mockClient, + ); + + vi.mocked(DefaultAgentCardResolver.prototype.resolve).mockResolvedValue({ + ...mockAgentCard, + url: 'http://test.agent/real/endpoint', + } as AgentCard); + + vi.mocked(ClientFactoryOptions.createFrom).mockImplementation( + (_defaults, overrides) => overrides as ClientFactoryOptions, + ); + + vi.mocked(createAuthenticatingFetchWithRetry).mockReturnValue( + authFetchMock, + ); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('should enforce the singleton pattern', () => { + const instance1 = A2AClientManager.getInstance(); + const instance2 = A2AClientManager.getInstance(); + expect(instance1).toBe(instance2); + }); + + describe('loadAgent', () => { + it('should create and cache an A2AClient', async () => { + const agentCard = await manager.loadAgent( + 'TestAgent', + 'http://test.agent/card', + ); + expect(agentCard).toMatchObject(mockAgentCard); + expect(manager.getAgentCard('TestAgent')).toBe(agentCard); + expect(manager.getClient('TestAgent')).toBeDefined(); + }); + + it('should throw an error if an agent with the same name is already loaded', async () => { + await manager.loadAgent('TestAgent', 'http://test.agent/card'); + await expect( + manager.loadAgent('TestAgent', 'http://another.agent/card'), + ).rejects.toThrow("Agent with name 'TestAgent' is already loaded."); + }); + + it('should use native fetch by default', async () => { + await manager.loadAgent('TestAgent', 'http://test.agent/card'); + expect(createAuthenticatingFetchWithRetry).not.toHaveBeenCalled(); + }); + + it('should use provided custom authentication handler', async () => { + const customAuthHandler = { + headers: vi.fn(), + shouldRetryWithHeaders: vi.fn(), + }; + await manager.loadAgent( + 'CustomAuthAgent', + 'http://custom.agent/card', + customAuthHandler as unknown as AuthenticationHandler, + ); + + expect(createAuthenticatingFetchWithRetry).toHaveBeenCalledWith( + expect.anything(), + customAuthHandler, + ); + }); + + it('should log a debug message upon loading an agent', async () => { + await manager.loadAgent('TestAgent', 'http://test.agent/card'); + expect(debugLogger.debug).toHaveBeenCalledWith( + "[A2AClientManager] Loaded agent 'TestAgent' from http://test.agent/card", + ); + }); + }); + + describe('sendMessage', () => { + beforeEach(async () => { + await manager.loadAgent('TestAgent', 'http://test.agent'); + }); + + it('should send a message to the correct agent', async () => { + sendMessageMock.mockResolvedValue({ + kind: 'message', + messageId: 'a', + parts: [], + role: 'agent', + } as SendMessageResult); + + await manager.sendMessage('TestAgent', 'Hello'); + expect(sendMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.anything(), + }), + ); + }); + + it('should use contextId and taskId when provided', async () => { + sendMessageMock.mockResolvedValue({ + kind: 'message', + messageId: 'a', + parts: [], + role: 'agent', + } as SendMessageResult); + + const expectedContextId = 'user-context-id'; + const expectedTaskId = 'user-task-id'; + + await manager.sendMessage('TestAgent', 'Hello', { + contextId: expectedContextId, + taskId: expectedTaskId, + }); + + const call = sendMessageMock.mock.calls[0][0]; + expect(call.message.contextId).toBe(expectedContextId); + expect(call.message.taskId).toBe(expectedTaskId); + }); + + it('should return result from client', async () => { + const mockResult = { + contextId: 'server-context-id', + id: 'ctx-1', + kind: 'task', + status: { state: 'working' }, + }; + + sendMessageMock.mockResolvedValueOnce(mockResult as SendMessageResult); + + const response = await manager.sendMessage('TestAgent', 'Hello'); + + expect(response).toEqual(mockResult); + }); + + it('should throw prefixed error on failure', async () => { + sendMessageMock.mockRejectedValueOnce(new Error('Network error')); + + await expect(manager.sendMessage('TestAgent', 'Hello')).rejects.toThrow( + 'A2AClient SendMessage Error [TestAgent]: Network error', + ); + }); + + it('should throw an error if the agent is not found', async () => { + await expect( + manager.sendMessage('NonExistentAgent', 'Hello'), + ).rejects.toThrow("Agent 'NonExistentAgent' not found."); + }); + }); + + describe('getTask', () => { + beforeEach(async () => { + await manager.loadAgent('TestAgent', 'http://test.agent'); + }); + + it('should get a task from the correct agent', async () => { + getTaskMock.mockResolvedValue({ + id: 'task123', + contextId: 'a', + kind: 'task', + status: { state: 'completed' }, + } as Task); + + await manager.getTask('TestAgent', 'task123'); + expect(getTaskMock).toHaveBeenCalledWith({ + id: 'task123', + }); + }); + + it('should throw prefixed error on failure', async () => { + getTaskMock.mockRejectedValueOnce(new Error('Network error')); + + await expect(manager.getTask('TestAgent', 'task123')).rejects.toThrow( + 'A2AClient getTask Error [TestAgent]: Network error', + ); + }); + + it('should throw an error if the agent is not found', async () => { + await expect( + manager.getTask('NonExistentAgent', 'task123'), + ).rejects.toThrow("Agent 'NonExistentAgent' not found."); + }); + }); + + describe('cancelTask', () => { + beforeEach(async () => { + await manager.loadAgent('TestAgent', 'http://test.agent'); + }); + + it('should cancel a task on the correct agent', async () => { + cancelTaskMock.mockResolvedValue({ + id: 'task123', + contextId: 'a', + kind: 'task', + status: { state: 'canceled' }, + } as Task); + + await manager.cancelTask('TestAgent', 'task123'); + expect(cancelTaskMock).toHaveBeenCalledWith({ + id: 'task123', + }); + }); + + it('should throw prefixed error on failure', async () => { + cancelTaskMock.mockRejectedValueOnce(new Error('Network error')); + + await expect(manager.cancelTask('TestAgent', 'task123')).rejects.toThrow( + 'A2AClient cancelTask Error [TestAgent]: Network error', + ); + }); + + it('should throw an error if the agent is not found', async () => { + await expect( + manager.cancelTask('NonExistentAgent', 'task123'), + ).rejects.toThrow("Agent 'NonExistentAgent' not found."); + }); + }); +}); diff --git a/packages/core/src/agents/a2a-client-manager.ts b/packages/core/src/agents/a2a-client-manager.ts new file mode 100644 index 0000000000..9eccca4ad4 --- /dev/null +++ b/packages/core/src/agents/a2a-client-manager.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AgentCard, Message, MessageSendParams, Task } from '@a2a-js/sdk'; +import { + type Client, + ClientFactory, + ClientFactoryOptions, + DefaultAgentCardResolver, + RestTransportFactory, + JsonRpcTransportFactory, + type AuthenticationHandler, + createAuthenticatingFetchWithRetry, +} from '@a2a-js/sdk/client'; +import { v4 as uuidv4 } from 'uuid'; +import { debugLogger } from '../utils/debugLogger.js'; + +export type SendMessageResult = Message | Task; + +/** + * Manages A2A clients and caches loaded agent information. + * Follows a singleton pattern to ensure a single client instance. + */ +export class A2AClientManager { + private static instance: A2AClientManager; + + // Each agent should manage their own context/taskIds/card/etc + private clients = new Map(); + private agentCards = new Map(); + + private constructor() {} + + /** + * Gets the singleton instance of the A2AClientManager. + */ + static getInstance(): A2AClientManager { + if (!A2AClientManager.instance) { + A2AClientManager.instance = new A2AClientManager(); + } + return A2AClientManager.instance; + } + + /** + * Resets the singleton instance. Only for testing purposes. + * @internal + */ + static resetInstanceForTesting() { + // @ts-expect-error - Resetting singleton for testing + A2AClientManager.instance = undefined; + } + + /** + * Loads an agent by fetching its AgentCard and caches the client. + * @param name The name to assign to the agent. + * @param agentCardUrl The full URL to the agent's card. + * @param authHandler Optional authentication handler to use for this agent. + * @returns The loaded AgentCard. + */ + async loadAgent( + name: string, + agentCardUrl: string, + authHandler?: AuthenticationHandler, + ): Promise { + if (this.clients.has(name)) { + throw new Error(`Agent with name '${name}' is already loaded.`); + } + + let fetchImpl = fetch; + if (authHandler) { + fetchImpl = createAuthenticatingFetchWithRetry(fetch, authHandler); + } + + const resolver = new DefaultAgentCardResolver({ fetchImpl }); + + const options = ClientFactoryOptions.createFrom( + ClientFactoryOptions.default, + { + transports: [ + new RestTransportFactory({ fetchImpl }), + new JsonRpcTransportFactory({ fetchImpl }), + ], + cardResolver: resolver, + }, + ); + + const factory = new ClientFactory(options); + const client = await factory.createFromUrl(agentCardUrl, ''); + const agentCard = await client.getAgentCard(); + + this.clients.set(name, client); + this.agentCards.set(name, agentCard); + + debugLogger.debug( + `[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`, + ); + + return agentCard; + } + + /** + * Sends a message to a loaded agent. + * @param agentName The name of the agent to send the message to. + * @param message The message content. + * @param options Optional context and task IDs to maintain conversation state. + * @returns The response from the agent (Message or Task). + * @throws Error if the agent returns an error response. + */ + async sendMessage( + agentName: string, + message: string, + options?: { contextId?: string; taskId?: string }, + ): Promise { + const client = this.clients.get(agentName); + if (!client) { + throw new Error(`Agent '${agentName}' not found.`); + } + + const messageParams: MessageSendParams = { + message: { + kind: 'message', + role: 'user', + messageId: uuidv4(), + parts: [{ kind: 'text', text: message }], + contextId: options?.contextId, + taskId: options?.taskId, + }, + configuration: { + blocking: true, + }, + }; + + try { + return await client.sendMessage(messageParams); + } catch (error: unknown) { + const prefix = `A2AClient SendMessage Error [${agentName}]`; + if (error instanceof Error) { + throw new Error(`${prefix}: ${error.message}`, { cause: error }); + } + throw new Error( + `${prefix}: Unexpected error during sendMessage: ${String(error)}`, + ); + } + } + + /** + * Retrieves a loaded agent card. + * @param name The name of the agent. + * @returns The agent card, or undefined if not found. + */ + getAgentCard(name: string): AgentCard | undefined { + return this.agentCards.get(name); + } + + /** + * Retrieves a loaded client. + * @param name The name of the agent. + * @returns The client, or undefined if not found. + */ + getClient(name: string): Client | undefined { + return this.clients.get(name); + } + + /** + * Retrieves a task from an agent. + * @param agentName The name of the agent. + * @param taskId The ID of the task to retrieve. + * @returns The task details. + */ + async getTask(agentName: string, taskId: string): Promise { + const client = this.clients.get(agentName); + if (!client) { + throw new Error(`Agent '${agentName}' not found.`); + } + try { + return await client.getTask({ id: taskId }); + } catch (error: unknown) { + const prefix = `A2AClient getTask Error [${agentName}]`; + if (error instanceof Error) { + throw new Error(`${prefix}: ${error.message}`, { cause: error }); + } + throw new Error(`${prefix}: Unexpected error: ${String(error)}`); + } + } + + /** + * Cancels a task on an agent. + * @param agentName The name of the agent. + * @param taskId The ID of the task to cancel. + * @returns The cancellation response. + */ + async cancelTask(agentName: string, taskId: string): Promise { + const client = this.clients.get(agentName); + if (!client) { + throw new Error(`Agent '${agentName}' not found.`); + } + try { + return await client.cancelTask({ id: taskId }); + } catch (error: unknown) { + const prefix = `A2AClient cancelTask Error [${agentName}]`; + if (error instanceof Error) { + throw new Error(`${prefix}: ${error.message}`, { cause: error }); + } + throw new Error(`${prefix}: Unexpected error: ${String(error)}`); + } + } +}