diff --git a/README.md b/README.md index 24c5f17..abb628b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,91 @@ npm install agent0-sdk import { SDK } from 'agent0-sdk'; ``` +### Adapters (Extensible Search + Chat Backends) + +Agent0 SDK uses an adapter pattern for agent discovery (search/vector search) and agent communication (chat). Register one or many adapters, and the SDK will use the first registered adapter by default (or you can choose one explicitly per call). + +To plug in your own backend, implement `AgentSearchAdapter` and/or `AgentChatAdapter` and register them: + +```ts +import { SDK, type AgentChatAdapter, type AgentSearchAdapter } from 'agent0-sdk'; + +const mySearchAdapter: AgentSearchAdapter = { + id: 'my-search', + async search() { + return { hits: [], total: 0 }; + }, +}; + +const myChatAdapter: AgentChatAdapter = { + id: 'my-chat', + async message() { + return { + sessionId: 'example-session-id', + mode: 'plaintext', + send: async () => ({ message: '' }), + }; + }, +}; + +const sdk = new SDK({ chainId: 11155111, rpcUrl: 'http://127.0.0.1:8545' }); +sdk.registerSearchAdapter(mySearchAdapter); +sdk.registerChatAdapter(myChatAdapter); + +await sdk.search({ query: 'hello', limit: 5 }, 'my-search'); +const agent = await sdk.loadAgent('11155111:28'); +await agent.message('hi', {}, 'my-chat'); +``` + +### Registry Broker Adapter (ERC-8004 Discovery + Chat) + +The Registry Broker API provides indexed discovery of **ERC‑8004 agents** and broker-managed chat sessions with those agents (including encrypted sessions when available). This is useful when you want discovery, routing, and session lifecycle handled by a broker rather than calling an agent endpoint directly. + +Agent0 SDK ships a Registry Broker adapter entrypoint. Install the client package, register the adapters, and use the standard `SDK` + `Agent` methods. + +```bash +npm install @hol-org/rb-client +``` + +Register adapters (search + chat), then use `sdk.search(...)` and `agent.message(...)`: + +```ts +import { SDK } from 'agent0-sdk'; +import { registerHashgraphRegistryBrokerAdapters } from 'agent0-sdk/registry-broker'; + +const sdk = new SDK({ + chainId: 11155111, + rpcUrl: process.env.RPC_URL || 'https://ethereum-sepolia-rpc.publicnode.com', +}); +const broker = { + baseUrl: process.env.REGISTRY_BROKER_BASE_URL || 'https://hol.org/registry/api/v1', + apiKey: process.env.REGISTRY_BROKER_API_KEY, // optional (depends on broker config) +}; +registerHashgraphRegistryBrokerAdapters(sdk, broker); + +const agentId = process.env.ERC8004_AGENT_ID?.trim(); +if (!agentId) throw new Error('Set ERC8004_AGENT_ID (e.g. "11155111:28")'); + +const agent = await sdk.loadAgent(agentId); +await agent.message('hi'); +``` + +If you register multiple adapters in the same category, pass the adapter ID explicitly (second argument to `sdk.search(...)`, third argument to `agent.message(...)`): + +```ts +import { + HASHGRAPH_REGISTRY_BROKER_CHAT_ADAPTER_ID, + HASHGRAPH_REGISTRY_BROKER_SEARCH_ADAPTER_ID, +} from 'agent0-sdk/registry-broker'; + +await sdk.search({ query: 'hello', limit: 5 }, HASHGRAPH_REGISTRY_BROKER_SEARCH_ADAPTER_ID); +await ( + await sdk + .createAgent('Remote Agent', 'Remote Agent handle') + .setA2A('https://example.com/agent-card.json', '0.30', false) +).message('hi', {}, HASHGRAPH_REGISTRY_BROKER_CHAT_ADAPTER_ID); +``` + ### Install from Source ```bash diff --git a/examples/broker-chat.ts b/examples/broker-chat.ts new file mode 100644 index 0000000..73b24ea --- /dev/null +++ b/examples/broker-chat.ts @@ -0,0 +1,123 @@ +import 'dotenv/config'; +import { SDK } from '../src/index.js'; +import { + HashgraphRegistryBrokerChatAdapter, + HashgraphRegistryBrokerSearchAdapter, +} from '../src/adapters/registry-broker.js'; + +const baseUrl = + process.env.REGISTRY_BROKER_BASE_URL?.trim() || 'https://hol.org/registry/api/v1'; +const apiKey = + process.env.REGISTRY_BROKER_API_KEY?.trim() || process.env.RB_API_KEY?.trim() || undefined; +const registry = process.env.ERC8004_REGISTRY?.trim() || 'erc-8004'; +const adapters = (() => { + const raw = process.env.ERC8004_ADAPTERS?.trim(); + if (!raw) { + return undefined; + } + const parsed = raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return parsed.length > 0 ? parsed : undefined; +})(); +const searchQuery = + process.env.ERC8004_AGENT_QUERY?.trim() || 'defillama-verifiable-agent'; +const targetAgentId = process.env.ERC8004_AGENT_ID?.trim() || ''; +const targetAgentUrl = process.env.ERC8004_AGENT_URL?.trim() || ''; + +const extractReply = (payload: { content?: string; message?: string }): string => + payload.content?.trim() || payload.message?.trim() || ''; + +async function main(): Promise { + const sdk = new SDK({ + chainId: Number(process.env.CHAIN_ID ?? '11155111'), + rpcUrl: process.env.RPC_URL?.trim() || 'http://127.0.0.1:8545', + }); + + sdk.registerSearchAdapter(new HashgraphRegistryBrokerSearchAdapter({ apiKey, baseUrl })); + sdk.registerChatAdapter(new HashgraphRegistryBrokerChatAdapter({ apiKey, baseUrl })); + + console.log(`Using Registry Broker baseUrl: ${baseUrl}`); + + if (targetAgentUrl) { + const agent = await sdk + .createAgent('Remote Agent', 'Remote Agent handle') + .setA2A(targetAgentUrl, '0.30', false); + const chat = await agent.message('Give a one-sentence summary of your capabilities.', { + historyTtlSeconds: 300, + encryption: { preference: 'disabled' }, + }); + + console.log(`Session established: ${chat.sessionId}`); + + const firstReply = extractReply(chat.response); + console.log('Agent reply:'); + console.log(firstReply || '[empty response]'); + + return; + } + + console.log(`Searching registry "${registry}" for agents (query="${searchQuery || '[empty]'}")`); + let result = await sdk.search({ + query: searchQuery, + registry, + adapters, + limit: 200, + sortBy: 'most-recent', + }); + + if (!result?.hits?.length) { + console.log('No agents found for this query; falling back to most recent agents.'); + result = await sdk.search({ + registry, + adapters, + limit: 200, + sortBy: 'most-recent', + }); + } + + if (!result?.hits?.length) { + throw new Error('No ERC-8004 agents found in this registry'); + } + + console.log(`Found ${result.hits.length} candidates`); + + if (!targetAgentId) { + console.log('Set ERC8004_AGENT_ID to open a chat session. Top results:'); + result.hits.slice(0, 5).forEach((hit) => { + console.log(`- ${(hit.name ?? '').trim() || '[no name]'} (${hit.id ?? '[no id]'})`); + }); + return; + } + + console.log(`Loading agent: ${targetAgentId}`); + const agent = await sdk.loadAgent(targetAgentId); + const chat = await agent.message('Give a one-sentence summary of your capabilities.', { + historyTtlSeconds: 300, + encryption: { preference: 'disabled' }, + }); + + console.log(`Session established: ${chat.sessionId}`); + + const firstReply = extractReply(chat.response); + console.log('Agent reply:'); + console.log(firstReply || '[empty response]'); + + const followUp = await agent.message( + 'Great. Please share one concrete task you can perform and what info you need from me.', + { + sessionId: chat.sessionId, + encryption: { preference: 'disabled' }, + }, + ); + + const secondReply = extractReply(followUp.response); + console.log('\nFollow-up reply:'); + console.log(secondReply || '[empty response]'); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/jest.config.js b/jest.config.js index 05d0bb4..c152afa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,8 +8,12 @@ export default { '^.+\\.ts$': ['ts-jest', { tsconfig: { types: ['jest', 'node'], + isolatedModules: true, }, useESM: true, + diagnostics: { + ignoreCodes: [151002], + }, }], }, setupFilesAfterEnv: ['/tests/setup.ts'], @@ -23,10 +27,9 @@ export default { testTimeout: 120000, // 2 minutes for integration tests with blockchain operations maxWorkers: 1, // Run tests sequentially to avoid nonce conflicts moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', + '^(\\.\\.?/src/.*)\\.js$': '$1.ts', }, transformIgnorePatterns: [ 'node_modules/(?!(ipfs-http-client)/)', ], }; - diff --git a/package-lock.json b/package-lock.json index f9a6df1..fbf565f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@graphql-codegen/introspection": "^5.0.0", "@graphql-codegen/typescript": "^5.0.2", "@graphql-codegen/typescript-operations": "^5.0.2", + "@hol-org/rb-client": "^0.1.147", "@types/jest": "^29.5.11", "@types/node": "^20.10.6", "@typescript-eslint/eslint-plugin": "^6.17.0", @@ -33,6 +34,14 @@ }, "engines": { "node": ">=18.0.0" + }, + "peerDependencies": { + "@hol-org/rb-client": "*" + }, + "peerDependenciesMeta": { + "@hol-org/rb-client": { + "optional": true + } } }, "node_modules/@adraffy/ens-normalize": { @@ -97,6 +106,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2165,6 +2175,73 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@hol-org/rb-client": { + "version": "0.1.147", + "resolved": "https://registry.npmjs.org/@hol-org/rb-client/-/rb-client-0.1.147.tgz", + "integrity": "sha512-vrzv2yE4f1m4IiU6BAlrwndjAWzgAfH0rwlOACNWeLMBKSwQRV/QxLh/xCgQBwARhKXoZH7e+ZWMR3LIjldOAQ==", + "dev": true, + "dependencies": { + "@noble/curves": "^2.0.1", + "zod": "^3.25.76" + }, + "peerDependencies": { + "@hashgraph/hedera-wallet-connect": "^2.0.4", + "@hashgraph/sdk": "^2.77.0", + "axios": "*", + "viem": "*", + "x402": "*", + "x402-axios": "*" + }, + "peerDependenciesMeta": { + "@hashgraph/hedera-wallet-connect": { + "optional": true + }, + "@hashgraph/sdk": { + "optional": true + }, + "axios": { + "optional": true + }, + "viem": { + "optional": true + }, + "x402": { + "optional": true + }, + "x402-axios": { + "optional": true + } + } + }, + "node_modules/@hol-org/rb-client/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@hol-org/rb-client/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3823,6 +3900,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -4062,6 +4140,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4434,6 +4513,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -5313,6 +5393,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7023,6 +7104,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8340,6 +8422,7 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", + "peer": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -9912,6 +9995,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10044,6 +10128,7 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", "license": "MIT", + "peer": true, "dependencies": { "@fastify/busboy": "^2.0.0" }, @@ -10301,6 +10386,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -10401,6 +10487,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a45ce0b..dd0d613 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,16 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./registry-broker": { + "types": "./dist/adapters/registry-broker.d.ts", + "import": "./dist/adapters/registry-broker.js" + } + }, "scripts": { "codegen": "graphql-codegen --config codegen.yml", "postinstall": "npm run codegen", @@ -53,7 +63,16 @@ "graphql-request": "^6.1.0", "ipfs-http-client": "^60.0.1" }, + "peerDependencies": { + "@hol-org/rb-client": "*" + }, + "peerDependenciesMeta": { + "@hol-org/rb-client": { + "optional": true + } + }, "devDependencies": { + "@hol-org/rb-client": "^0.1.147", "@graphql-codegen/cli": "^6.0.1", "@graphql-codegen/introspection": "^5.0.0", "@graphql-codegen/typescript": "^5.0.2", diff --git a/src/adapters/registry-broker-utils.ts b/src/adapters/registry-broker-utils.ts new file mode 100644 index 0000000..e44f446 --- /dev/null +++ b/src/adapters/registry-broker-utils.ts @@ -0,0 +1,145 @@ +import type { + ChatConversationHandle, + CreateSessionRequestPayload, + CreateSessionResponse, + SearchParams, + StartChatOptions, + SendMessageRequestPayload, + SendMessageResponse, + VectorSearchRequest, + VectorSearchResponse, +} from '@hol-org/rb-client'; +import type { + AgentChatConversationHandle, + AgentChatSendMessageResponse, + AgentSearchResult, + AgentVectorSearchResponse, +} from '../core/adapters.js'; +import { RegistryBrokerClient } from '@hol-org/rb-client'; + +export interface RegistryBrokerClientChatApi { + readonly chat: { + start: (options: StartChatOptions) => Promise; + createSession: ( + payload: CreateSessionRequestPayload, + ) => Promise; + sendMessage: ( + payload: SendMessageRequestPayload, + ) => Promise; + }; + vectorSearch: (request: VectorSearchRequest) => Promise; +} + +export const isRegistryBrokerClientChatApi = ( + client: RegistryBrokerClient, +): client is RegistryBrokerClient & RegistryBrokerClientChatApi => + 'vectorSearch' in client && 'chat' in client; + +const extractNativeIdFromUaid = (uaid: string): string | null => { + const match = /(?:^|;)nativeId=([^;]+)/.exec(uaid); + const candidate = match?.[1]?.trim() ?? ''; + return candidate.length > 0 ? candidate : null; +}; + +export const mapSearchResult = (result: { + hits?: Array<{ + id: string; + uaid?: string; + originalId?: string; + registry?: string; + name?: string; + description?: string | null; + }>; + total?: number; +}): AgentSearchResult => ({ + hits: (result.hits ?? []).map((hit) => ({ + id: + typeof hit.originalId === 'string' + ? hit.originalId + : (() => { + const uaid = + typeof hit.uaid === 'string' && hit.uaid.trim().length > 0 + ? hit.uaid.trim() + : null; + const nativeId = uaid ? extractNativeIdFromUaid(uaid) : null; + return nativeId ?? hit.id; + })(), + registry: hit.registry, + name: hit.name, + description: hit.description ?? undefined, + })), + total: result.total, +}); + +export const mapVectorSearchResponse = ( + response: VectorSearchResponse, +): AgentVectorSearchResponse => ({ + hits: (response.hits ?? []).map((hit) => ({ + agent: hit.agent + ? { + id: + typeof hit.agent.originalId === 'string' + ? hit.agent.originalId + : (() => { + const uaid = + typeof hit.agent.uaid === 'string' && + hit.agent.uaid.trim().length > 0 + ? hit.agent.uaid.trim() + : null; + const nativeId = uaid ? extractNativeIdFromUaid(uaid) : null; + return nativeId ?? hit.agent.id; + })(), + registry: hit.agent.registry, + name: hit.agent.name, + description: hit.agent.description, + } + : undefined, + score: hit.score ?? undefined, + highlights: hit.highlights ?? undefined, + })), + total: response.total, + took: response.took, +}); + +export const mapSendMessageResponse = ( + response: SendMessageResponse, +): AgentChatSendMessageResponse => ({ + message: response.message, + content: response.content, + historyLength: Array.isArray(response.history) ? response.history.length : 0, +}); + +export const mapConversationHandle = ( + handle: ChatConversationHandle, +): AgentChatConversationHandle => ({ + sessionId: handle.sessionId, + mode: handle.mode === 'encrypted' ? 'encrypted' : 'plaintext', + send: async (options) => { + const plaintext = options.plaintext ?? options.message; + if (!plaintext) { + throw new Error('plaintext is required to send an encrypted message'); + } + const response = await handle.send({ + message: options.message, + plaintext, + auth: options.auth, + streaming: options.streaming, + }); + return mapSendMessageResponse(response); + }, +}); + +export const resolveChatTarget = (options: { + uaid?: string; + agentUrl?: string; +}): { uaid: string } | { agentUrl: string } => { + const trimmedUaid = options.uaid?.trim(); + if (trimmedUaid) { + return { uaid: trimmedUaid }; + } + const trimmedUrl = options.agentUrl?.trim(); + if (trimmedUrl) { + return { agentUrl: trimmedUrl }; + } + throw new Error('Either uaid or agentUrl is required for chat'); +}; diff --git a/src/adapters/registry-broker.ts b/src/adapters/registry-broker.ts new file mode 100644 index 0000000..d76eea4 --- /dev/null +++ b/src/adapters/registry-broker.ts @@ -0,0 +1,438 @@ +import { RegistryBrokerClient } from '@hol-org/rb-client'; +import type { + CreateSessionResponse, + JsonValue, + RegistryBrokerClientOptions, + SearchParams, +} from '@hol-org/rb-client'; +import type { + AgentAdapterHost, + AgentChatAdapter, + AgentChatConversationHandle, + AgentChatOpenConversationRequest, + AgentChatSendMessageResponse, + AgentSearchAdapter, + AgentSearchOptions, + AgentSearchResult, + AgentVectorSearchRequest, + AgentVectorSearchResponse, +} from '../core/adapters.js'; +import { + isRegistryBrokerClientChatApi, + mapConversationHandle, + mapSearchResult, + mapSendMessageResponse, + mapVectorSearchResponse, + resolveChatTarget, + type RegistryBrokerClientChatApi, +} from './registry-broker-utils.js'; + +export const ERC8004_DEFAULT_ADAPTER = 'erc8004-adapter'; +export const ERC8004_DEFAULT_REGISTRY = 'erc-8004'; + +export const HASHGRAPH_REGISTRY_BROKER_SEARCH_ADAPTER_ID = + 'hashgraph-registry-broker/search'; +export const HASHGRAPH_REGISTRY_BROKER_CHAT_ADAPTER_ID = + 'hashgraph-registry-broker/chat'; + +type BrokerSearchHit = { + id: string; + uaid?: string; + originalId?: string; + registry?: string; + name?: string; + description?: string | null; +}; + +type BrokerSearchResult = { + hits: BrokerSearchHit[]; + total?: number; +}; + +type HttpErrorLike = { + status?: number; +}; + +const delay = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const isRetryableBrokerError = (error: HttpErrorLike): boolean => + error.status === 502 || error.status === 503 || error.status === 504; + +const withRetry = async (fn: () => Promise): Promise => { + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + return await fn(); + } catch (error) { + if (!isRetryableBrokerError(error as HttpErrorLike) || attempt === maxAttempts) { + throw error; + } + await delay(450 * attempt); + } + } + + throw new Error('Unreachable retry state'); +}; + +const isJsonRecord = (value: JsonValue): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const normalizeString = (value: JsonValue): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const appendList = ( + query: URLSearchParams, + key: string, + values: SearchParams[keyof SearchParams], +): void => { + if (!Array.isArray(values)) { + return; + } + values.forEach((value) => { + const trimmed = normalizeString(value); + if (trimmed) { + query.append(key, trimmed); + } + }); +}; + +const buildSearchQuery = (params: SearchParams): string => { + const query = new URLSearchParams(); + + if (typeof params.q === 'string') { + const trimmed = params.q.trim(); + if (trimmed.length > 0) { + query.set('q', trimmed); + } + } + if (typeof params.page === 'number') { + query.set('page', params.page.toString()); + } + if (typeof params.limit === 'number') { + query.set('limit', params.limit.toString()); + } + if (typeof params.sortBy === 'string') { + const trimmed = params.sortBy.trim(); + if (trimmed.length > 0) { + query.set('sortBy', trimmed); + } + } + if (typeof params.registry === 'string') { + const trimmed = params.registry.trim(); + if (trimmed.length > 0) { + query.set('registry', trimmed); + } + } + appendList(query, 'registries', params.registries); + if (typeof params.minTrust === 'number') { + query.set('minTrust', params.minTrust.toString()); + } + appendList(query, 'capabilities', params.capabilities); + appendList(query, 'protocols', params.protocols); + appendList(query, 'adapters', params.adapters); + if (params.metadata) { + Object.entries(params.metadata).forEach(([key, values]) => { + if (!key || !Array.isArray(values) || values.length === 0) { + return; + } + const trimmedKey = key.trim(); + if (trimmedKey.length === 0) { + return; + } + values.forEach((value) => { + if (value === undefined || value === null) { + return; + } + query.append(`metadata.${trimmedKey}`, String(value)); + }); + }); + } + if (typeof params.type === 'string') { + const trimmed = params.type.trim(); + if (trimmed.length > 0 && trimmed.toLowerCase() !== 'all') { + query.set('type', trimmed); + } + } + if (params.verified === true) { + query.set('verified', 'true'); + } + if (params.online === true) { + query.set('online', 'true'); + } + + return query.size > 0 ? `?${query.toString()}` : ''; +}; + +const parseBrokerSearchResult = (value: JsonValue): BrokerSearchResult => { + if (!isJsonRecord(value)) { + throw new Error('Registry Broker search returned a non-object payload'); + } + const hitsRaw = value.hits; + const hitsValue = + Array.isArray(hitsRaw) && hitsRaw.every((hit) => isJsonRecord(hit)) + ? hitsRaw + : []; + + const hits: BrokerSearchHit[] = []; + hitsValue.forEach((hit) => { + const id = typeof hit.id === 'string' ? hit.id : ''; + if (!id) { + return; + } + hits.push({ + id, + uaid: typeof hit.uaid === 'string' ? hit.uaid : undefined, + originalId: typeof hit.originalId === 'string' ? hit.originalId : undefined, + registry: typeof hit.registry === 'string' ? hit.registry : undefined, + name: typeof hit.name === 'string' ? hit.name : undefined, + description: typeof hit.description === 'string' ? hit.description : null, + }); + }); + + const total = typeof value.total === 'number' ? value.total : undefined; + + return { hits, total }; +}; + +export class HashgraphRegistryBrokerSearchAdapter implements AgentSearchAdapter { + readonly id = HASHGRAPH_REGISTRY_BROKER_SEARCH_ADAPTER_ID; + private readonly client: RegistryBrokerClient & RegistryBrokerClientChatApi; + + constructor(options: RegistryBrokerClientOptions = {}) { + const client = new RegistryBrokerClient({ ...options }); + if (!isRegistryBrokerClientChatApi(client)) { + throw new Error( + 'Installed @hol-org/rb-client version does not expose the expected Registry Broker APIs. Please upgrade @hol-org/rb-client to the latest version.', + ); + } + this.client = client; + } + + private async searchBroker(params: SearchParams): Promise { + const suffix = buildSearchQuery(params); + const raw = await withRetry(() => + this.client.requestJson(`/search${suffix}`, { + method: 'GET', + }), + ); + return parseBrokerSearchResult(raw); + } + + async search(options: AgentSearchOptions): Promise { + const registry = + options.registry ?? options.registries?.[0] ?? ERC8004_DEFAULT_REGISTRY; + const searchParamsBase: SearchParams = { + q: options.query, + limit: options.limit ?? 25, + sortBy: options.sortBy ?? 'most-recent', + registry, + registries: options.registries, + minTrust: options.minTrust, + protocols: options.protocols, + }; + + if (options.adapters) { + const result = await this.searchBroker({ + ...searchParamsBase, + adapters: options.adapters, + }); + return mapSearchResult(result); + } + + if (registry !== ERC8004_DEFAULT_REGISTRY) { + const result = await this.searchBroker(searchParamsBase); + return mapSearchResult(result); + } + + const resultWithDefaultAdapter = await this.searchBroker({ + ...searchParamsBase, + adapters: [ERC8004_DEFAULT_ADAPTER], + }); + if (resultWithDefaultAdapter.hits?.length) { + return mapSearchResult(resultWithDefaultAdapter); + } + + const resultWithoutAdapter = await this.searchBroker(searchParamsBase); + return mapSearchResult(resultWithoutAdapter); + } + + async vectorSearch( + request: AgentVectorSearchRequest, + ): Promise { + const result = await this.client.vectorSearch({ + query: request.query, + limit: request.limit, + offset: request.offset, + filter: request.filter + ? { + registry: request.filter.registry, + protocols: request.filter.protocols, + adapter: request.filter.adapters, + } + : undefined, + }); + return mapVectorSearchResponse(result); + } +} + +export class HashgraphRegistryBrokerChatAdapter implements AgentChatAdapter { + readonly id = HASHGRAPH_REGISTRY_BROKER_CHAT_ADAPTER_ID; + private readonly client: RegistryBrokerClient & RegistryBrokerClientChatApi; + private readonly uaidByAgentId = new Map(); + + constructor(options: RegistryBrokerClientOptions = {}) { + const client = new RegistryBrokerClient({ ...options }); + if (!isRegistryBrokerClientChatApi(client)) { + throw new Error( + 'Installed @hol-org/rb-client version does not expose the expected Registry Broker APIs. Please upgrade @hol-org/rb-client to the latest version.', + ); + } + this.client = client; + } + + private async resolveChatTargetFromAgent(request: { + agent: AgentChatOpenConversationRequest['agent']; + auth?: AgentChatOpenConversationRequest['auth']; + senderUaid?: AgentChatOpenConversationRequest['senderUaid']; + historyTtlSeconds?: AgentChatOpenConversationRequest['historyTtlSeconds']; + }): Promise<{ + uaid?: string; + agentUrl?: string; + }> { + const agentId = request.agent.agentId?.trim() ?? ''; + if (agentId) { + const cached = this.uaidByAgentId.get(agentId); + if (cached) { + return { uaid: cached }; + } + + const search = async (adapters?: string[]): Promise => { + const suffix = buildSearchQuery({ + registry: ERC8004_DEFAULT_REGISTRY, + adapters, + limit: 5, + sortBy: 'most-recent', + metadata: { + nativeId: [agentId], + }, + }); + const raw = await withRetry(() => + this.client.requestJson(`/search${suffix}`, { + method: 'GET', + }), + ); + return parseBrokerSearchResult(raw); + }; + + const resultWithAdapter = await search([ERC8004_DEFAULT_ADAPTER]); + const uaidFromAdapter = resultWithAdapter.hits[0]?.uaid?.trim(); + const uaidFromFallback = uaidFromAdapter + ? undefined + : (await search()).hits[0]?.uaid?.trim(); + + const uaid = uaidFromAdapter ?? uaidFromFallback ?? ''; + if (!uaid) { + throw new Error(`Unable to resolve broker UAID for agentId "${agentId}"`); + } + this.uaidByAgentId.set(agentId, uaid); + return { uaid }; + } + + const agentUrl = request.agent.a2aEndpoint?.trim() ?? request.agent.mcpEndpoint?.trim() ?? ''; + if (agentUrl) { + return { agentUrl }; + } + + throw new Error('Agent does not have an agentId or a chat-capable endpoint'); + } + + private createPlaintextHandle(sessionId: string): AgentChatConversationHandle { + return { + sessionId, + mode: 'plaintext', + send: async (options): Promise => { + const message = options.message?.trim() ?? ''; + if (!message) { + throw new Error('message is required'); + } + const response = await withRetry(() => + this.client.chat.sendMessage({ + sessionId, + message, + streaming: options.streaming, + auth: options.auth, + }), + ); + return mapSendMessageResponse(response); + }, + }; + } + + async message( + request: AgentChatOpenConversationRequest, + ): Promise { + const preference = request.encryption?.preference ?? 'preferred'; + const sessionId = request.sessionId?.trim() ?? ''; + if (sessionId) { + if (preference === 'required') { + throw new Error( + 'Encrypted chat cannot be resumed from an existing sessionId; start a new chat session instead.', + ); + } + return this.createPlaintextHandle(sessionId); + } + + const resolved = await this.resolveChatTargetFromAgent(request); + const target = resolveChatTarget(resolved); + + if (preference !== 'disabled') { + try { + const handle = await withRetry(() => + this.client.chat.start({ + ...target, + historyTtlSeconds: request.historyTtlSeconds, + auth: request.auth, + senderUaid: request.senderUaid, + encryption: request.encryption, + }), + ); + return mapConversationHandle(handle); + } catch (error) { + if (preference === 'required') { + throw error; + } + } + } + + const response: CreateSessionResponse = await withRetry(() => + this.client.chat.createSession({ + ...target, + historyTtlSeconds: request.historyTtlSeconds, + auth: request.auth, + senderUaid: request.senderUaid, + encryptionRequested: false, + }), + ); + const createdSessionId = response.sessionId?.trim() ?? ''; + if (!createdSessionId) { + throw new Error('Registry Broker createSession did not return a sessionId'); + } + return this.createPlaintextHandle(createdSessionId); + } +} + +export const registerHashgraphRegistryBrokerAdapters = ( + host: AgentAdapterHost, + options: RegistryBrokerClientOptions = {}, +): void => { + host.registerSearchAdapter(new HashgraphRegistryBrokerSearchAdapter(options)); + host.registerChatAdapter(new HashgraphRegistryBrokerChatAdapter(options)); +}; diff --git a/src/core/adapters.ts b/src/core/adapters.ts new file mode 100644 index 0000000..c918779 --- /dev/null +++ b/src/core/adapters.ts @@ -0,0 +1,144 @@ +import type { Agent } from './agent.js'; + +export type AgentAdapterId = string; + +export interface AgentSearchHit { + id?: string; + registry?: string; + name?: string; + description?: string; +} + +export interface AgentSearchOptions { + query?: string; + limit?: number; + sortBy?: string; + registry?: string; + registries?: string[]; + adapters?: string[]; + minTrust?: number; + protocols?: string[]; +} + +export interface AgentSearchResult { + hits: AgentSearchHit[]; + total?: number; +} + +export interface AgentVectorSearchFilter { + registry?: string; + registries?: string[]; + protocols?: string[]; + adapters?: string[]; +} + +export interface AgentVectorSearchRequest { + query: string; + limit?: number; + offset?: number; + filter?: AgentVectorSearchFilter; +} + +export interface AgentVectorSearchHit { + agent?: AgentSearchHit; + score?: number; + highlights?: Record; +} + +export interface AgentVectorSearchResponse { + hits?: AgentVectorSearchHit[]; + total?: number; + took?: number; +} + +export interface AgentChatAuthConfig { + token?: string; + username?: string; + password?: string; + headerName?: string; + headerValue?: string; + headers?: Record; +} + +export interface AgentChatSendMessageResponse { + message?: string; + content?: string; + historyLength?: number; +} + +export interface AgentChatEncryptionOptions { + preference?: 'preferred' | 'required' | 'disabled'; +} + +export interface AgentChatOpenConversationRequest { + agent: Agent; + sessionId?: string; + historyTtlSeconds?: number; + auth?: AgentChatAuthConfig; + senderUaid?: string; + encryption?: AgentChatEncryptionOptions; +} + +export interface AgentChatConversationHandle { + sessionId: string; + mode: 'encrypted' | 'plaintext'; + send: (options: { + message?: string; + plaintext?: string; + auth?: AgentChatAuthConfig; + streaming?: boolean; + }) => Promise; +} + +export interface AgentChatResult { + sessionId: string; + response: AgentChatSendMessageResponse; + mode: 'encrypted' | 'plaintext'; +} + +export interface AgentSearchAdapter { + readonly id: AgentAdapterId; + search: (options: AgentSearchOptions) => Promise; + vectorSearch?: (request: AgentVectorSearchRequest) => Promise; +} + +export interface AgentChatAdapter { + readonly id: AgentAdapterId; + message: ( + request: AgentChatOpenConversationRequest, + ) => Promise; +} + +export interface AgentAdapterHost { + registerSearchAdapter: (adapter: AgentSearchAdapter) => void; + registerChatAdapter: (adapter: AgentChatAdapter) => void; +} + +export class AgentAdapterRegistry implements AgentAdapterHost { + private readonly searchAdapters = new Map(); + private readonly chatAdapters = new Map(); + + registerSearchAdapter(adapter: AgentSearchAdapter): void { + this.searchAdapters.set(adapter.id, adapter); + } + + registerChatAdapter(adapter: AgentChatAdapter): void { + this.chatAdapters.set(adapter.id, adapter); + } + + getSearchAdapter(id: AgentAdapterId): AgentSearchAdapter | undefined { + return this.searchAdapters.get(id); + } + + getChatAdapter(id: AgentAdapterId): AgentChatAdapter | undefined { + return this.chatAdapters.get(id); + } + + listSearchAdapters(): AgentAdapterId[] { + return [...this.searchAdapters.keys()]; + } + + listChatAdapters(): AgentAdapterId[] { + return [...this.chatAdapters.keys()]; + } +} diff --git a/src/core/agent.ts b/src/core/agent.ts index d972962..3ced0cb 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -14,6 +14,14 @@ import { EndpointCrawler } from './endpoint-crawler.js'; import { parseAgentId } from '../utils/id-format.js'; import { TIMEOUTS } from '../utils/constants.js'; import { validateSkill, validateDomain } from './oasf-validator.js'; +import type { + AgentAdapterId, + AgentChatAuthConfig, + AgentChatEncryptionOptions, + AgentChatResult, +} from './adapters.js'; + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; /** * Agent class for managing individual agents @@ -61,6 +69,56 @@ export class Agent { return ep?.value; } + private resolveChatAdapter(adapterId?: AgentAdapterId) { + const resolved = adapterId ?? this.sdk.listChatAdapters()[0]; + if (!resolved) { + throw new Error('No chat adapters registered'); + } + const adapter = this.sdk.getChatAdapter(resolved); + if (!adapter) { + throw new Error(`No chat adapter registered for "${resolved}"`); + } + return adapter; + } + + async message( + text: string, + options: { + sessionId?: string; + historyTtlSeconds?: number; + auth?: AgentChatAuthConfig; + senderUaid?: string; + encryption?: AgentChatEncryptionOptions; + streaming?: boolean; + } = {}, + adapterId?: AgentAdapterId, + ): Promise { + const message = text.trim(); + if (!message) { + throw new Error('message is required for chat'); + } + + const adapter = this.resolveChatAdapter(adapterId); + + const handle = await adapter.message({ + agent: this, + sessionId: options.sessionId, + historyTtlSeconds: options.historyTtlSeconds, + auth: options.auth, + senderUaid: options.senderUaid, + encryption: options.encryption, + }); + + const response = await handle.send({ + message, + plaintext: message, + auth: options.auth, + streaming: options.streaming, + }); + + return { sessionId: handle.sessionId, response, mode: handle.mode }; + } + get ensEndpoint(): string | undefined { const ep = this.registrationFile.endpoints.find((e) => e.type === EndpointType.ENS); return ep?.value; @@ -98,7 +156,7 @@ export class Agent { ); // Try to fetch capabilities from the endpoint (soft fail) - const meta: Record = { version }; + const meta: Record = { version }; if (autoFetch) { try { const capabilities = await this._endpointCrawler.fetchMcpCapabilities(endpoint); @@ -131,7 +189,7 @@ export class Agent { ); // Try to fetch capabilities from the endpoint (soft fail) - const meta: Record = { version }; + const meta: Record = { version }; if (autoFetch) { try { const capabilities = await this._endpointCrawler.fetchA2aCapabilities(agentcard); @@ -362,7 +420,7 @@ export class Agent { return this; } - setMetadata(kv: Record): this { + setMetadata(kv: Record): this { // Mark all provided keys as dirty for (const key of Object.keys(kv)) { this._dirtyMetadata.add(key); @@ -373,7 +431,7 @@ export class Agent { return this; } - getMetadata(): Record { + getMetadata(): Record { return { ...this.registrationFile.metadata }; } @@ -766,4 +824,3 @@ export class Agent { throw new Error('Could not extract agent ID from transaction receipt - no Registered or Transfer event found'); } } - diff --git a/src/core/sdk.ts b/src/core/sdk.ts index 12011b2..04f25cb 100644 --- a/src/core/sdk.ts +++ b/src/core/sdk.ts @@ -12,17 +12,26 @@ import type { RegistrationFile, Endpoint, } from '../models/interfaces.js'; -import type { AgentRegistrationFile as SubgraphRegistrationFile } from '../models/generated/subgraph-types.js'; import type { AgentId, ChainId, Address, URI } from '../models/types.js'; import { EndpointType, TrustModel } from '../models/enums.js'; import { formatAgentId, parseAgentId } from '../utils/id-format.js'; import { IPFS_GATEWAYS, TIMEOUTS } from '../utils/constants.js'; -import { Web3Client, type TransactionOptions } from './web3-client.js'; +import { Web3Client } from './web3-client.js'; import { IPFSClient, type IPFSClientConfig } from './ipfs-client.js'; import { SubgraphClient } from './subgraph-client.js'; import { FeedbackManager } from './feedback-manager.js'; import { AgentIndexer } from './indexer.js'; import { Agent } from './agent.js'; +import { + AgentAdapterRegistry, + type AgentAdapterId, + type AgentChatAdapter, + type AgentSearchAdapter, + type AgentSearchOptions, + type AgentSearchResult, + type AgentVectorSearchRequest, + type AgentVectorSearchResponse, +} from './adapters.js'; import { IDENTITY_REGISTRY_ABI, REPUTATION_REGISTRY_ABI, @@ -31,6 +40,7 @@ import { DEFAULT_SUBGRAPH_URLS, } from './contracts.js'; + export interface SDKConfig { chainId: ChainId; rpcUrl: string; @@ -55,6 +65,7 @@ export class SDK { private _subgraphClient?: SubgraphClient; private readonly _feedbackManager: FeedbackManager; private readonly _indexer: AgentIndexer; + private readonly _adapters = new AgentAdapterRegistry(); private _identityRegistry?: ethers.Contract; private _reputationRegistry?: ethers.Contract; private _validationRegistry?: ethers.Contract; @@ -168,6 +179,73 @@ export class SDK { return { ...this._registries }; } + registerSearchAdapter(adapter: AgentSearchAdapter): void { + this._adapters.registerSearchAdapter(adapter); + } + + registerChatAdapter(adapter: AgentChatAdapter): void { + this._adapters.registerChatAdapter(adapter); + } + + getSearchAdapter(id: AgentAdapterId): AgentSearchAdapter | undefined { + return this._adapters.getSearchAdapter(id); + } + + getChatAdapter(id: AgentAdapterId): AgentChatAdapter | undefined { + return this._adapters.getChatAdapter(id); + } + + listSearchAdapters(): AgentAdapterId[] { + return this._adapters.listSearchAdapters(); + } + + listChatAdapters(): AgentAdapterId[] { + return this._adapters.listChatAdapters(); + } + + private resolveSearchAdapterId(adapterId?: AgentAdapterId): AgentAdapterId { + const resolved = adapterId ?? this.listSearchAdapters()[0]; + if (!resolved) { + throw new Error('No search adapters registered'); + } + return resolved; + } + + private resolveChatAdapterId(adapterId?: AgentAdapterId): AgentAdapterId { + const resolved = adapterId ?? this.listChatAdapters()[0]; + if (!resolved) { + throw new Error('No chat adapters registered'); + } + return resolved; + } + + async search( + options: AgentSearchOptions, + adapterId?: AgentAdapterId, + ): Promise { + const resolvedId = this.resolveSearchAdapterId(adapterId); + const adapter = this.getSearchAdapter(resolvedId); + if (!adapter) { + throw new Error(`No search adapter registered for "${resolvedId}"`); + } + return adapter.search(options); + } + + async vectorSearch( + request: AgentVectorSearchRequest, + adapterId?: AgentAdapterId, + ): Promise { + const resolvedId = this.resolveSearchAdapterId(adapterId); + const adapter = this.getSearchAdapter(resolvedId); + if (!adapter) { + throw new Error(`No search adapter registered for "${resolvedId}"`); + } + if (!adapter.vectorSearch) { + throw new Error(`Search adapter "${resolvedId}" does not support vectorSearch`); + } + return adapter.vectorSearch(request); + } + /** * Get subgraph client for a specific chain */ @@ -792,4 +870,3 @@ export class SDK { return this._subgraphClient; } } - diff --git a/src/index.ts b/src/index.ts index 592a9c4..8687461 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,3 +26,5 @@ export { AgentIndexer } from './core/indexer.js'; // Export contract definitions export * from './core/contracts.js'; +// Adapter interfaces +export * from './core/adapters.js'; diff --git a/tests/registry-broker-erc8004.test.ts b/tests/registry-broker-erc8004.test.ts new file mode 100644 index 0000000..1e20ca6 --- /dev/null +++ b/tests/registry-broker-erc8004.test.ts @@ -0,0 +1,212 @@ +import { + HashgraphRegistryBrokerChatAdapter, + HashgraphRegistryBrokerSearchAdapter, +} from '../src/adapters/registry-broker.ts'; +import { SDK } from '../src/index.js'; +import type { + AgentChatSendMessageResponse, + AgentSearchHit, + AgentVectorSearchResponse, +} from '../src/core/adapters.ts'; + +const baseUrl = process.env.REGISTRY_BROKER_BASE_URL?.trim(); + +const resolveApiKey = (): string | undefined => + process.env.REGISTRY_BROKER_API_KEY?.trim() || + process.env.RB_API_KEY?.trim() || + undefined; + +const defaultQuery = + process.env.ERC8004_AGENT_QUERY?.trim() || 'defillama-verifiable-agent'; +const defaultRegistry = process.env.ERC8004_REGISTRY?.trim() || 'erc-8004'; +const defaultAdapters = (() => { + const raw = process.env.ERC8004_ADAPTERS?.trim(); + if (!raw) { + return undefined; + } + const adapters = raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return adapters.length > 0 ? adapters : undefined; +})(); + +const targetAgentId = process.env.ERC8004_AGENT_ID?.trim() || ''; +const targetAgentUrl = process.env.ERC8004_AGENT_URL?.trim() || ''; + +const pickAgentWithId = (hits: AgentSearchHit[]): AgentSearchHit | null => { + for (const hit of hits) { + if (typeof hit.id === 'string' && hit.id.trim().length > 0) { + return hit; + } + } + return null; +}; + +const extractReplyText = (payload: AgentChatSendMessageResponse): string => { + if (typeof payload.content === 'string' && payload.content.trim().length > 0) { + return payload.content.trim(); + } + if (typeof payload.message === 'string') { + return payload.message.trim(); + } + return ''; +}; + +type VectorSearchStatusPayload = { + vectorStatus?: { + healthy?: boolean; + }; +}; + +const fetchVectorSearchHealthy = async (): Promise => { + if (!baseUrl) { + return false; + } + try { + const response = await fetch(`${baseUrl}/search/status`); + if (!response.ok) { + return false; + } + const payload = (await response.json()) as VectorSearchStatusPayload; + return payload.vectorStatus?.healthy === true; + } catch { + return false; + } +}; + +const describeRegistryBroker = baseUrl ? describe : describe.skip; + +const delay = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +type RegistryBrokerErrorShape = { + status?: number; + body?: { + error?: string; + details?: string; + }; +}; + +const isTransientAgentConnectError = (error: RegistryBrokerErrorShape): boolean => { + const status = error.status; + if (status !== 500 && status !== 502 && status !== 503 && status !== 504) { + return false; + } + const err = typeof error.body?.error === 'string' ? error.body.error : ''; + const details = typeof error.body?.details === 'string' ? error.body.details : ''; + return ( + err.toLowerCase().includes('failed to connect to agent') || + details.toLowerCase().includes('unavailable') || + details.toLowerCase().includes('no connection established') + ); +}; + +describeRegistryBroker('RegistryBroker ERC-8004 integration', () => { + const sdk = new SDK({ + chainId: 11155111, + rpcUrl: process.env.RPC_URL?.trim() || 'http://127.0.0.1:8545', + }); + + sdk.registerSearchAdapter( + new HashgraphRegistryBrokerSearchAdapter({ + baseUrl, + apiKey: resolveApiKey(), + }), + ); + sdk.registerChatAdapter( + new HashgraphRegistryBrokerChatAdapter({ + baseUrl, + apiKey: resolveApiKey(), + }), + ); + + let vectorSearchHealthy = false; + + beforeAll(async () => { + vectorSearchHealthy = await fetchVectorSearchHealthy(); + }); + + it('searches for ERC-8004 agents', async () => { + const result = await sdk.search({ + query: defaultQuery, + registry: defaultRegistry, + adapters: defaultAdapters, + limit: 8, + sortBy: 'most-recent', + }); + + expect(Array.isArray(result.hits)).toBe(true); + expect(typeof result.total).toBe('number'); + + if (result.hits.length === 0) { + return; + } + + const selected = pickAgentWithId(result.hits); + expect(selected).not.toBeNull(); + }); + + const itChat = targetAgentId.length > 0 || targetAgentUrl.length > 0 ? it : it.skip; + itChat('creates a session and exchanges a message with an ERC-8004 agent', async () => { + const agent = targetAgentUrl + ? await sdk + .createAgent('Remote Agent', 'Remote Agent handle') + .setA2A(targetAgentUrl, '0.30', false) + : await sdk.loadAgent(targetAgentId); + + const runOnce = async () => + agent.message('Provide a concise, one sentence summary of your available capabilities.', { + historyTtlSeconds: 180, + encryption: { preference: 'disabled' }, + }); + + try { + const result = await runOnce(); + expect(result.sessionId.length).toBeGreaterThan(0); + const reply = extractReplyText(result.response); + expect(reply.length).toBeGreaterThan(0); + } catch (error) { + if (isTransientAgentConnectError(error)) { + await delay(750); + try { + const result = await runOnce(); + expect(result.sessionId.length).toBeGreaterThan(0); + const reply = extractReplyText(result.response); + expect(reply.length).toBeGreaterThan(0); + } catch (retryError) { + if (isTransientAgentConnectError(retryError)) { + return; + } + throw retryError; + } + } + throw error; + } + }); + + it('performs a vector search', async () => { + if (!vectorSearchHealthy) { + return; + } + const query = defaultQuery.trim().length > 0 ? defaultQuery : 'claude'; + const response: AgentVectorSearchResponse = await sdk.vectorSearch({ + query, + limit: 3, + filter: { + registry: defaultRegistry, + adapters: defaultAdapters, + }, + }); + expect(Array.isArray(response.hits)).toBe(true); + expect(typeof response.total).toBe('number'); + if (response.hits && response.hits.length > 0) { + const idPresent = response.hits.some( + (hit) => typeof hit.agent?.id === 'string' && hit.agent.id.length > 0, + ); + expect(idPresent).toBe(true); + } + }); +});