gopherhole_openclaw_a2a 0.3.14 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts DELETED
@@ -1,385 +0,0 @@
1
- /**
2
- * A2A Channel Plugin for OpenClaw
3
- * Enables communication with other AI agents via A2A protocol
4
- */
5
-
6
- // Use minimal type imports - mostly self-contained
7
- const DEFAULT_ACCOUNT_ID = 'default';
8
- function normalizeAccountId(id?: string): string {
9
- return id?.trim()?.toLowerCase() || DEFAULT_ACCOUNT_ID;
10
- }
11
-
12
- import { A2AConnectionManager } from './connection.js';
13
- import { sendChatMessage, connectToGateway, disconnectFromGateway } from './gateway-client.js';
14
- import { a2aLog } from './logger.js';
15
- import type {
16
- A2AMessage,
17
- A2AChannelConfig,
18
- ResolvedA2AAccount,
19
- } from './types.js';
20
-
21
- // Minimal runtime interface - what we actually need
22
- interface OpenClawRuntime {
23
- handleInbound(params: {
24
- channel: string;
25
- chatId: string;
26
- userId: string;
27
- username?: string;
28
- text: string;
29
- isGroup: boolean;
30
- metadata?: Record<string, unknown>;
31
- }): Promise<{ text?: string } | null>;
32
- }
33
-
34
- // Runtime state
35
- let connectionManager: A2AConnectionManager | null = null;
36
- let currentRuntime: OpenClawRuntime | null = null;
37
-
38
- export function setA2ARuntime(runtime: unknown): void {
39
- currentRuntime = runtime as OpenClawRuntime;
40
- }
41
-
42
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
- type OpenClawConfig = any;
44
-
45
- // Minimal channel plugin interfaces (self-contained)
46
- interface ChannelAccountSnapshot {
47
- accountId: string;
48
- name: string;
49
- enabled: boolean;
50
- configured: boolean;
51
- [key: string]: unknown;
52
- }
53
-
54
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
- type ChannelPlugin<T = any> = {
56
- id: string;
57
- meta: unknown;
58
- capabilities: unknown;
59
- reload?: unknown;
60
- config: {
61
- listAccountIds: (cfg: unknown) => string[];
62
- resolveAccount: (cfg: unknown, accountId?: string) => T;
63
- defaultAccountId: (cfg: unknown) => string;
64
- setAccountEnabled: (opts: { cfg: unknown; accountId?: string; enabled: boolean }) => unknown;
65
- deleteAccount: (opts: { cfg: unknown; accountId: string }) => unknown;
66
- isConfigured: (account: T) => boolean;
67
- describeAccount: (account: T) => ChannelAccountSnapshot;
68
- resolveAllowFrom: (opts: { cfg: unknown; accountId?: string }) => string[];
69
- formatAllowFrom: (opts: { allowFrom: string[] }) => string[];
70
- };
71
- security?: unknown;
72
- messaging?: unknown;
73
- setup?: unknown;
74
- outbound?: unknown;
75
- status?: unknown;
76
- gateway?: {
77
- startAccount: (ctx: {
78
- account: T;
79
- cfg: unknown;
80
- accountId: string;
81
- runtime: unknown;
82
- abortSignal?: AbortSignal;
83
- setStatus: (status: Record<string, unknown>) => void;
84
- log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
85
- }) => Promise<(() => Promise<void>) | void>;
86
- };
87
- };
88
-
89
- function resolveA2AConfig(cfg: OpenClawConfig): A2AChannelConfig {
90
- return cfg?.channels?.a2a ?? {};
91
- }
92
-
93
- function resolveA2AAccount(opts: {
94
- cfg: OpenClawConfig;
95
- accountId?: string;
96
- }): ResolvedA2AAccount {
97
- const config = resolveA2AConfig(opts.cfg);
98
- const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID;
99
-
100
- return {
101
- accountId,
102
- name: config.agentName ?? 'A2A',
103
- enabled: config.enabled ?? false,
104
- configured: !!(config.bridgeUrl && config.apiKey),
105
- agentId: config.agentId ?? 'openclaw',
106
- bridgeUrl: config.bridgeUrl ?? null,
107
- config,
108
- };
109
- }
110
-
111
- const meta = {
112
- id: 'a2a',
113
- label: 'A2A',
114
- selectionLabel: 'A2A (Agent-to-Agent)',
115
- detailLabel: 'A2A Protocol',
116
- docsPath: '/channels/a2a',
117
- docsLabel: 'a2a',
118
- blurb: 'Communicate with other AI agents via GopherHole A2A protocol.',
119
- systemImage: 'bubble.left.and.bubble.right',
120
- aliases: ['agent2agent', 'gopherhole'],
121
- order: 200,
122
- };
123
-
124
- export const a2aPlugin: ChannelPlugin<ResolvedA2AAccount> = {
125
- id: 'a2a',
126
- meta,
127
- capabilities: {
128
- chatTypes: ['direct'],
129
- media: false, // Text-only for now
130
- reactions: false,
131
- edit: false,
132
- unsend: false,
133
- reply: false,
134
- },
135
- reload: { configPrefixes: ['channels.a2a'] },
136
- config: {
137
- listAccountIds: () => [DEFAULT_ACCOUNT_ID],
138
- resolveAccount: (cfg, accountId) =>
139
- resolveA2AAccount({ cfg: cfg as OpenClawConfig, accountId }),
140
- defaultAccountId: () => DEFAULT_ACCOUNT_ID,
141
- setAccountEnabled: ({ cfg, enabled }) => {
142
- const next = cfg as OpenClawConfig;
143
- return {
144
- ...next,
145
- channels: {
146
- ...next.channels,
147
- a2a: {
148
- ...(next.channels as Record<string, unknown>)?.a2a as object,
149
- enabled,
150
- },
151
- },
152
- } as OpenClawConfig;
153
- },
154
- deleteAccount: ({ cfg }) => cfg as OpenClawConfig,
155
- isConfigured: (account) => account.configured,
156
- describeAccount: (account): ChannelAccountSnapshot => ({
157
- accountId: account.accountId,
158
- name: account.name,
159
- enabled: account.enabled,
160
- configured: account.configured,
161
- }),
162
- resolveAllowFrom: () => [],
163
- formatAllowFrom: ({ allowFrom }) => allowFrom,
164
- },
165
- security: {
166
- resolveDmPolicy: ({ account }) => ({
167
- policy: 'open', // A2A connections are pre-configured, no pairing needed
168
- allowFrom: [],
169
- policyPath: 'channels.a2a.dmPolicy',
170
- allowFromPath: 'channels.a2a.',
171
- approveHint: '',
172
- normalizeEntry: (raw) => raw,
173
- }),
174
- collectWarnings: () => [],
175
- },
176
- messaging: {
177
- normalizeTarget: (target) => target?.trim() ?? '',
178
- targetResolver: {
179
- looksLikeId: (id) => /^[a-z0-9_@-]+$/i.test(id),
180
- hint: '<agentId> (e.g. @memory, @echo)',
181
- },
182
- formatTargetDisplay: ({ target }) => target ?? '',
183
- },
184
- setup: {
185
- resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
186
- applyAccountName: ({ cfg }) => cfg as OpenClawConfig,
187
- validateInput: ({ input }) => {
188
- if (!input.httpUrl && !input.customArgs) {
189
- return 'A2A requires --http-url (bridge URL) or bridgeUrl + apiKey in config.';
190
- }
191
- return null;
192
- },
193
- applyAccountConfig: ({ cfg, input }) => {
194
- const next = cfg as OpenClawConfig;
195
- return {
196
- ...next,
197
- channels: {
198
- ...next.channels,
199
- a2a: {
200
- ...(next.channels as Record<string, unknown>)?.a2a as object,
201
- enabled: true,
202
- ...(input.httpUrl ? { bridgeUrl: input.httpUrl } : {}),
203
- },
204
- },
205
- } as OpenClawConfig;
206
- },
207
- },
208
- outbound: {
209
- deliveryMode: 'direct',
210
- textChunkLimit: 10000,
211
- resolveTarget: ({ to }) => {
212
- const trimmed = to?.trim();
213
- if (!trimmed) {
214
- return {
215
- ok: false,
216
- error: new Error('A2A requires --to <agentId>'),
217
- };
218
- }
219
- return { ok: true, to: trimmed };
220
- },
221
- sendText: async ({ to, text }) => {
222
- if (!connectionManager) {
223
- return { channel: 'a2a', success: false, error: 'A2A not connected' };
224
- }
225
- try {
226
- const response = await connectionManager.sendMessage(to, text);
227
- return {
228
- channel: 'a2a',
229
- success: true,
230
- messageId: response.status,
231
- response: response.text,
232
- };
233
- } catch (err) {
234
- return {
235
- channel: 'a2a',
236
- success: false,
237
- error: (err as Error).message,
238
- };
239
- }
240
- },
241
- },
242
- status: {
243
- defaultRuntime: {
244
- accountId: DEFAULT_ACCOUNT_ID,
245
- running: false,
246
- lastStartAt: null,
247
- lastStopAt: null,
248
- lastError: null,
249
- },
250
- collectStatusIssues: () => [],
251
- buildChannelSummary: ({ snapshot }) => ({
252
- configured: snapshot.configured ?? false,
253
- running: snapshot.running ?? false,
254
- lastStartAt: snapshot.lastStartAt ?? null,
255
- lastStopAt: snapshot.lastStopAt ?? null,
256
- lastError: snapshot.lastError ?? null,
257
- }),
258
- probeAccount: async () => ({ ok: connectionManager !== null }),
259
- buildAccountSnapshot: async ({ account, runtime }) => {
260
- const connectionStatus = connectionManager?.listAgents() ?? [];
261
- const availableAgents = await connectionManager?.listAvailableAgents() ?? [];
262
- return {
263
- accountId: account.accountId,
264
- name: account.name,
265
- enabled: account.enabled,
266
- configured: account.configured,
267
- running: runtime?.running ?? false,
268
- connected: connectionStatus.some((a) => a.connected),
269
- hubStatus: connectionStatus,
270
- availableAgents,
271
- lastStartAt: runtime?.lastStartAt ?? null,
272
- lastStopAt: runtime?.lastStopAt ?? null,
273
- lastError: runtime?.lastError ?? null,
274
- };
275
- },
276
- },
277
- gateway: {
278
- startAccount: async (ctx) => {
279
- const account = ctx.account;
280
- const config = account.config;
281
-
282
- ctx.log?.info(`[a2a] Starting A2A channel`);
283
- ctx.setStatus({ accountId: account.accountId });
284
-
285
- connectionManager = new A2AConnectionManager(config);
286
-
287
- // Set up message handler for incoming messages
288
- connectionManager.setMessageHandler(async (agentId: string, message: A2AMessage) => {
289
- if (message.type === 'message' && message.from) {
290
- const text = message.content?.parts
291
- ?.filter((p) => p.kind === 'text')
292
- .map((p) => p.text)
293
- .join('\n') ?? '';
294
-
295
- if (!text) return;
296
-
297
- // Log incoming message
298
- a2aLog.messageReceived(message.from, message.taskId, text);
299
-
300
- // Validate taskId early
301
- if (!message.taskId || message.taskId.startsWith('gph-')) {
302
- a2aLog.error('taskid_invalid', `Invalid taskId "${message.taskId}" - response relay will fail!`, { from: message.from });
303
- }
304
-
305
- // Route to OpenClaw's reply pipeline via gateway JSON-RPC
306
- try {
307
- // Use chat.send to route the message through the agent
308
- // Session key format: agent:<agentId>:<channel>:<chatId>
309
- const sessionKey = `agent:main:a2a:${message.from}`;
310
- a2aLog.messageProcessing(message.taskId, sessionKey);
311
-
312
- // Add A2A context so the agent knows to relay its full response
313
- const a2aContext = `[A2A Request from agent "${message.from}"]\n\n${text}\n\n[Note: Your complete response will be sent back to the requesting agent. Include all relevant information in your reply.]`;
314
-
315
- const response = await sendChatMessage(sessionKey, a2aContext);
316
-
317
- // Log captured response
318
- if (response?.text) {
319
- a2aLog.responseCaptured(message.taskId, response.text);
320
- } else {
321
- a2aLog.error('response_empty', 'No response text captured from agent', { taskId: message.taskId });
322
- }
323
-
324
- // Send response back to the agent via GopherHole
325
- if (response?.text && response.text.trim()) {
326
- const sent = connectionManager?.sendResponseViaGopherHole(
327
- message.from,
328
- message.taskId,
329
- response.text,
330
- message.contextId
331
- );
332
- a2aLog.responseSent(message.taskId || 'unknown', message.from, true);
333
- } else {
334
- a2aLog.error('response_relay_failed', 'No response text to relay', { taskId: message.taskId });
335
- connectionManager?.sendResponseViaGopherHole(
336
- message.from,
337
- message.taskId,
338
- `[No response generated]`,
339
- message.contextId
340
- );
341
- a2aLog.responseSent(message.taskId || 'unknown', message.from, false);
342
- }
343
- } catch (err) {
344
- a2aLog.error('handler_error', (err as Error).message, { taskId: message.taskId, stack: (err as Error).stack });
345
- connectionManager?.sendResponseViaGopherHole(
346
- message.from,
347
- message.taskId,
348
- `Error: ${(err as Error).message}`,
349
- message.contextId
350
- );
351
- }
352
- }
353
- });
354
-
355
- await connectionManager.start();
356
-
357
- ctx.setStatus({
358
- accountId: account.accountId,
359
- running: true,
360
- lastStartAt: Date.now(),
361
- });
362
-
363
- ctx.log?.info(`[a2a] A2A channel started`);
364
-
365
- // Return cleanup function
366
- return async () => {
367
- ctx.log?.info(`[a2a] Stopping A2A channel`);
368
- await connectionManager?.stop();
369
- connectionManager = null;
370
- ctx.setStatus({
371
- accountId: account.accountId,
372
- running: false,
373
- lastStopAt: Date.now(),
374
- });
375
- };
376
- },
377
- },
378
- };
379
-
380
- /**
381
- * Get the connection manager for direct access (e.g., from tools)
382
- */
383
- export function getA2AConnectionManager(): A2AConnectionManager | null {
384
- return connectionManager;
385
- }
@@ -1,298 +0,0 @@
1
- /**
2
- * Unit tests for OpenClaw plugin discovery methods
3
- * Tests the new discovery params: tag, skillTag, contentMode, sort, offset, scope
4
- */
5
-
6
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
- import { A2AConnectionManager } from './connection';
8
-
9
- // Mock fetch
10
- const mockFetch = vi.fn();
11
- global.fetch = mockFetch;
12
-
13
- // Mock @gopherhole/sdk
14
- vi.mock('@gopherhole/sdk', () => ({
15
- GopherHole: vi.fn().mockImplementation(() => ({
16
- connect: vi.fn(),
17
- disconnect: vi.fn(),
18
- on: vi.fn(),
19
- connected: false,
20
- id: null,
21
- })),
22
- getTaskResponseText: vi.fn(),
23
- }));
24
-
25
- describe('A2AConnectionManager Discovery', () => {
26
- let manager: A2AConnectionManager;
27
- const mockApiKey = 'gph_test_key';
28
-
29
- beforeEach(() => {
30
- manager = new A2AConnectionManager({
31
- enabled: true,
32
- apiKey: mockApiKey,
33
- bridgeUrl: 'wss://test.gopherhole.ai/ws',
34
- });
35
- mockFetch.mockReset();
36
- });
37
-
38
- afterEach(() => {
39
- vi.clearAllMocks();
40
- });
41
-
42
- const mockDiscoverResponse = {
43
- agents: [
44
- {
45
- id: 'agent-1',
46
- name: 'Test Agent',
47
- description: 'A test agent',
48
- verified: true,
49
- tenantName: 'TestTenant',
50
- avgRating: 4.5,
51
- },
52
- ],
53
- };
54
-
55
- const setupMockRpcResponse = (result: any) => {
56
- mockFetch.mockResolvedValueOnce({
57
- ok: true,
58
- json: () => Promise.resolve({ result }),
59
- });
60
- };
61
-
62
- describe('discoverAgents() with new params', () => {
63
- it('should call discover with tag param', async () => {
64
- setupMockRpcResponse(mockDiscoverResponse);
65
-
66
- await manager.discoverAgents({ tag: 'ai' });
67
-
68
- expect(mockFetch).toHaveBeenCalledWith(
69
- expect.any(String),
70
- expect.objectContaining({
71
- method: 'POST',
72
- body: expect.stringContaining('"tag":"ai"'),
73
- })
74
- );
75
- });
76
-
77
- it('should call discover with skillTag param', async () => {
78
- setupMockRpcResponse(mockDiscoverResponse);
79
-
80
- await manager.discoverAgents({ skillTag: 'nlp' });
81
-
82
- expect(mockFetch).toHaveBeenCalledWith(
83
- expect.any(String),
84
- expect.objectContaining({
85
- body: expect.stringContaining('"skillTag":"nlp"'),
86
- })
87
- );
88
- });
89
-
90
- it('should call discover with contentMode param', async () => {
91
- setupMockRpcResponse(mockDiscoverResponse);
92
-
93
- await manager.discoverAgents({ contentMode: 'text/markdown' });
94
-
95
- expect(mockFetch).toHaveBeenCalledWith(
96
- expect.any(String),
97
- expect.objectContaining({
98
- body: expect.stringContaining('"contentMode":"text/markdown"'),
99
- })
100
- );
101
- });
102
-
103
- it('should call discover with sort param', async () => {
104
- setupMockRpcResponse(mockDiscoverResponse);
105
-
106
- await manager.discoverAgents({ sort: 'rating' });
107
-
108
- expect(mockFetch).toHaveBeenCalledWith(
109
- expect.any(String),
110
- expect.objectContaining({
111
- body: expect.stringContaining('"sort":"rating"'),
112
- })
113
- );
114
- });
115
-
116
- it('should call discover with offset param', async () => {
117
- setupMockRpcResponse(mockDiscoverResponse);
118
-
119
- await manager.discoverAgents({ offset: 20 });
120
-
121
- expect(mockFetch).toHaveBeenCalledWith(
122
- expect.any(String),
123
- expect.objectContaining({
124
- body: expect.stringContaining('"offset":20'),
125
- })
126
- );
127
- });
128
-
129
- it('should call discover with scope=tenant', async () => {
130
- setupMockRpcResponse(mockDiscoverResponse);
131
-
132
- await manager.discoverAgents({ scope: 'tenant' });
133
-
134
- expect(mockFetch).toHaveBeenCalledWith(
135
- expect.any(String),
136
- expect.objectContaining({
137
- body: expect.stringContaining('"scope":"tenant"'),
138
- })
139
- );
140
- });
141
- });
142
-
143
- describe('discoverAgents() with combined params', () => {
144
- it('should combine multiple new params', async () => {
145
- setupMockRpcResponse(mockDiscoverResponse);
146
-
147
- await manager.discoverAgents({
148
- tag: 'ai',
149
- skillTag: 'nlp',
150
- sort: 'popular',
151
- offset: 10,
152
- });
153
-
154
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
155
- expect(body.params.tag).toBe('ai');
156
- expect(body.params.skillTag).toBe('nlp');
157
- expect(body.params.sort).toBe('popular');
158
- expect(body.params.offset).toBe(10);
159
- });
160
-
161
- it('should combine query with new params', async () => {
162
- setupMockRpcResponse(mockDiscoverResponse);
163
-
164
- await manager.discoverAgents({
165
- query: 'weather',
166
- tag: 'api',
167
- contentMode: 'application/json',
168
- sort: 'recent',
169
- limit: 25,
170
- });
171
-
172
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
173
- expect(body.params.query).toBe('weather');
174
- expect(body.params.tag).toBe('api');
175
- expect(body.params.contentMode).toBe('application/json');
176
- expect(body.params.sort).toBe('recent');
177
- expect(body.params.limit).toBe(25);
178
- });
179
- });
180
-
181
- describe('discoverAgents() edge cases', () => {
182
- it('should handle empty results', async () => {
183
- setupMockRpcResponse({ agents: [] });
184
-
185
- const result = await manager.discoverAgents({ tag: 'nonexistent' });
186
-
187
- expect(result).toHaveLength(0);
188
- });
189
-
190
- it('should handle RPC errors gracefully', async () => {
191
- mockFetch.mockResolvedValueOnce({
192
- ok: true,
193
- json: () => Promise.resolve({ error: { message: 'Not found' } }),
194
- });
195
-
196
- const result = await manager.discoverAgents({ tag: 'test' });
197
-
198
- expect(result).toEqual([]);
199
- });
200
-
201
- it('should handle network errors', async () => {
202
- mockFetch.mockRejectedValueOnce(new Error('Network error'));
203
-
204
- const result = await manager.discoverAgents({ tag: 'test' });
205
-
206
- expect(result).toEqual([]);
207
- });
208
-
209
- it('should return empty array when apiKey not configured', async () => {
210
- const managerNoKey = new A2AConnectionManager({
211
- enabled: true,
212
- // No apiKey
213
- });
214
-
215
- const result = await managerNoKey.discoverAgents({ tag: 'test' });
216
-
217
- expect(result).toEqual([]);
218
- expect(mockFetch).not.toHaveBeenCalled();
219
- });
220
- });
221
-
222
- describe('discoverAgents() sort values', () => {
223
- it('should accept rating sort', async () => {
224
- setupMockRpcResponse(mockDiscoverResponse);
225
- await manager.discoverAgents({ sort: 'rating' });
226
-
227
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
228
- expect(body.params.sort).toBe('rating');
229
- });
230
-
231
- it('should accept popular sort', async () => {
232
- setupMockRpcResponse(mockDiscoverResponse);
233
- await manager.discoverAgents({ sort: 'popular' });
234
-
235
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
236
- expect(body.params.sort).toBe('popular');
237
- });
238
-
239
- it('should accept recent sort', async () => {
240
- setupMockRpcResponse(mockDiscoverResponse);
241
- await manager.discoverAgents({ sort: 'recent' });
242
-
243
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
244
- expect(body.params.sort).toBe('recent');
245
- });
246
- });
247
-
248
- describe('discoverAgents() response mapping', () => {
249
- it('should map response correctly', async () => {
250
- setupMockRpcResponse({
251
- agents: [
252
- {
253
- id: 'agent-1',
254
- name: 'Test Agent',
255
- description: 'A test agent',
256
- verified: true,
257
- tenantName: 'TestTenant',
258
- avgRating: 4.5,
259
- },
260
- ],
261
- });
262
-
263
- const result = await manager.discoverAgents({ query: 'test' });
264
-
265
- expect(result).toEqual([
266
- {
267
- id: 'agent-1',
268
- name: 'Test Agent',
269
- description: 'A test agent',
270
- verified: true,
271
- tenantName: 'TestTenant',
272
- avgRating: 4.5,
273
- },
274
- ]);
275
- });
276
- });
277
-
278
- describe('listAvailableAgents()', () => {
279
- it('should list available agents', async () => {
280
- setupMockRpcResponse({
281
- agents: [
282
- {
283
- id: 'agent-1',
284
- name: 'Test Agent',
285
- description: 'A test agent',
286
- verified: true,
287
- accessType: 'same-tenant',
288
- },
289
- ],
290
- });
291
-
292
- const result = await manager.listAvailableAgents();
293
-
294
- expect(result).toHaveLength(1);
295
- expect(result[0].accessType).toBe('same-tenant');
296
- });
297
- });
298
- });