gopherhole_openclaw_a2a 0.3.1 → 0.3.3

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.
@@ -0,0 +1,122 @@
1
+ # OpenClaw A2A Plugin - Response Relay Fix Guide
2
+
3
+ ## Problem
4
+ When Agent A sends to Agent B via GopherHole, Agent B can receive and process the message, but the response doesn't get relayed back. Agent A sees their original message echoed instead of the actual response.
5
+
6
+ ## Root Cause Analysis
7
+
8
+ ### 1. TaskId Flow Issue
9
+ The `taskId` is critical for routing responses back. If it's missing or invalid:
10
+ - `connection.ts` generates a fake `gph-<timestamp>` ID
11
+ - `respond()` sends to this fake task
12
+ - GopherHole ignores it (task doesn't exist)
13
+ - `waitForTask` falls back to history, returning the original request
14
+
15
+ ### 2. Agent ID Mismatch
16
+ GopherHole validates that responses come from the correct agent:
17
+ ```sql
18
+ SELECT context_id FROM tasks WHERE id = ? AND server_agent_id = ?
19
+ ```
20
+ If the agent's connected ID doesn't match `server_agent_id`, the response is silently dropped.
21
+
22
+ ## Debugging Steps
23
+
24
+ ### Step 1: Add Logging to connection.ts
25
+
26
+ In `handleIncomingMessage()`:
27
+ ```typescript
28
+ private handleIncomingMessage(message: Message): void {
29
+ console.log(`[a2a] RAW incoming message:`, JSON.stringify(message, null, 2));
30
+
31
+ if (!this.messageHandler) return;
32
+
33
+ console.log(`[a2a] Received message from ${message.from}, taskId=${message.taskId}`);
34
+ // ... rest of handler
35
+ }
36
+ ```
37
+
38
+ In `sendResponseViaGopherHole()`:
39
+ ```typescript
40
+ sendResponseViaGopherHole(
41
+ _targetAgentId: string,
42
+ taskId: string,
43
+ text: string,
44
+ _contextId?: string
45
+ ): void {
46
+ console.log(`[a2a] Attempting respond - taskId=${taskId}, connected=${this.connected}, text=${text.slice(0, 100)}`);
47
+
48
+ if (!taskId || taskId.startsWith('gph-')) {
49
+ console.error(`[a2a] WARNING: Invalid taskId "${taskId}" - response will be lost!`);
50
+ }
51
+ // ... rest of method
52
+ }
53
+ ```
54
+
55
+ ### Step 2: Verify Agent ID Configuration
56
+
57
+ Check your config:
58
+ ```yaml
59
+ channels:
60
+ a2a:
61
+ enabled: true
62
+ agentId: "agent-XXXXXXXX" # Must match your GopherHole agent ID
63
+ apiKey: "gph_..."
64
+ ```
65
+
66
+ The `agentId` here should match exactly what's in your GopherHole dashboard.
67
+
68
+ ### Step 3: Check SDK Message Event
69
+
70
+ In the SDK (`@gopherhole/sdk`), the message handler should receive taskId:
71
+ ```typescript
72
+ this.emit('message', {
73
+ from: data.from,
74
+ taskId: data.taskId, // Must be present!
75
+ payload: data.payload,
76
+ timestamp: data.timestamp || Date.now(),
77
+ });
78
+ ```
79
+
80
+ If `data.taskId` is undefined in the raw WebSocket message from GopherHole, that's a server-side bug.
81
+
82
+ ## The Fix
83
+
84
+ ### Option A: Ensure taskId is propagated (SDK/Server fix)
85
+
86
+ The GopherHole hub's `deliverMessage` should always include taskId:
87
+ ```typescript
88
+ conn.ws.send(JSON.stringify({
89
+ type: 'message',
90
+ from: message.from,
91
+ taskId: message.taskId, // This must be present
92
+ payload: message.payload,
93
+ }));
94
+ ```
95
+
96
+ ### Option B: Plugin resilience (Client-side workaround)
97
+
98
+ If taskId isn't available, the plugin could store a mapping:
99
+ ```typescript
100
+ // In handleIncomingMessage:
101
+ const taskId = message.taskId || `pending-${message.from}-${Date.now()}`;
102
+ if (!message.taskId) {
103
+ // Store for later - need server-side support for this
104
+ console.warn('[a2a] No taskId in message - response routing may fail');
105
+ }
106
+ ```
107
+
108
+ ## Testing
109
+
110
+ 1. Send a simple message to your agent via GopherHole
111
+ 2. Check logs for:
112
+ - `[a2a] RAW incoming message:` - does it have taskId?
113
+ - `[a2a] Attempting respond - taskId=` - is taskId valid?
114
+ 3. If taskId is missing/invalid, the issue is upstream (SDK or GopherHole server)
115
+
116
+ ## Quick Checklist
117
+
118
+ - [ ] Agent ID in config matches GopherHole dashboard
119
+ - [ ] API key is valid and has correct permissions
120
+ - [ ] WebSocket connection is established (check for "Connected to GopherHole Hub via SDK" log)
121
+ - [ ] Incoming messages have valid taskId (not undefined or gph-*)
122
+ - [ ] Agent's `respond()` is actually being called after processing
package/dist/index.js CHANGED
@@ -55,8 +55,13 @@ const plugin = {
55
55
  return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: 'A2A channel not running' }) }] };
56
56
  }
57
57
  if (action === 'list') {
58
- const agents = manager.listAgents();
59
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'ok', agents }) }] };
58
+ const hubStatus = manager.listAgents();
59
+ const availableAgents = await manager.listAvailableAgents();
60
+ return { content: [{ type: 'text', text: JSON.stringify({
61
+ status: 'ok',
62
+ connected: hubStatus.some(h => h.connected),
63
+ agents: availableAgents
64
+ }) }] };
60
65
  }
61
66
  if (action === 'send') {
62
67
  if (!agentId || (!message && !imagePath && !filePath)) {
@@ -9,6 +9,7 @@ function normalizeAccountId(id) {
9
9
  }
10
10
  import { A2AConnectionManager } from './connection.js';
11
11
  import { sendChatMessage } from './gateway-client.js';
12
+ import { a2aLog } from './logger.js';
12
13
  // Runtime state
13
14
  let connectionManager = null;
14
15
  let currentRuntime = null;
@@ -212,21 +213,41 @@ export const a2aPlugin = {
212
213
  .join('\n') ?? '';
213
214
  if (!text)
214
215
  return;
216
+ // Log incoming message
217
+ a2aLog.messageReceived(message.from, message.taskId, text);
218
+ // Validate taskId early
219
+ if (!message.taskId || message.taskId.startsWith('gph-')) {
220
+ a2aLog.error('taskid_invalid', `Invalid taskId "${message.taskId}" - response relay will fail!`, { from: message.from });
221
+ }
215
222
  // Route to OpenClaw's reply pipeline via gateway JSON-RPC
216
223
  try {
217
- ctx.log?.info(`[a2a] Routing message from ${message.from}: "${text.slice(0, 100)}..."`);
218
224
  // Use chat.send to route the message through the agent
219
225
  // Session key format: agent:<agentId>:<channel>:<chatId>
220
226
  const sessionKey = `agent:main:a2a:${message.from}`;
221
- const response = await sendChatMessage(sessionKey, text);
222
- ctx.log?.info(`[a2a] chat.send returned: ${response ? `text=${response.text?.slice(0, 50)}...` : 'null'}`);
223
- // Send response back to the agent via GopherHole
227
+ a2aLog.messageProcessing(message.taskId, sessionKey);
228
+ // Add A2A context so the agent knows to relay its full response
229
+ 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.]`;
230
+ const response = await sendChatMessage(sessionKey, a2aContext);
231
+ // Log captured response
224
232
  if (response?.text) {
225
- connectionManager?.sendResponseViaGopherHole(message.from, message.taskId, response.text, message.contextId);
233
+ a2aLog.responseCaptured(message.taskId, response.text);
234
+ }
235
+ else {
236
+ a2aLog.error('response_empty', 'No response text captured from agent', { taskId: message.taskId });
237
+ }
238
+ // Send response back to the agent via GopherHole
239
+ if (response?.text && response.text.trim()) {
240
+ const sent = connectionManager?.sendResponseViaGopherHole(message.from, message.taskId, response.text, message.contextId);
241
+ a2aLog.responseSent(message.taskId || 'unknown', message.from, true);
242
+ }
243
+ else {
244
+ a2aLog.error('response_relay_failed', 'No response text to relay', { taskId: message.taskId });
245
+ connectionManager?.sendResponseViaGopherHole(message.from, message.taskId, `[No response generated]`, message.contextId);
246
+ a2aLog.responseSent(message.taskId || 'unknown', message.from, false);
226
247
  }
227
248
  }
228
249
  catch (err) {
229
- ctx.log?.error(`[a2a] Error handling message:`, err);
250
+ a2aLog.error('handler_error', err.message, { taskId: message.taskId, stack: err.stack });
230
251
  connectionManager?.sendResponseViaGopherHole(message.from, message.taskId, `Error: ${err.message}`, message.contextId);
231
252
  }
232
253
  }
@@ -102,6 +102,12 @@ export class A2AConnectionManager {
102
102
  if (!this.messageHandler)
103
103
  return;
104
104
  console.log(`[a2a] Received message from ${message.from}, taskId=${message.taskId}`);
105
+ console.log(`[a2a] Raw message payload:`, JSON.stringify(message.payload, null, 2).slice(0, 500));
106
+ // Validate taskId - critical for response routing
107
+ if (!message.taskId) {
108
+ console.error(`[a2a] WARNING: No taskId in incoming message! Response relay will fail.`);
109
+ console.error(`[a2a] Full message object:`, JSON.stringify(message, null, 2));
110
+ }
105
111
  // Convert SDK message to our A2AMessage format
106
112
  const a2aMsg = {
107
113
  type: 'message',
@@ -116,6 +122,7 @@ export class A2AConnectionManager {
116
122
  })),
117
123
  },
118
124
  };
125
+ console.log(`[a2a] Dispatching to messageHandler with taskId=${a2aMsg.taskId}`);
119
126
  this.messageHandler('gopherhole', a2aMsg).catch((err) => {
120
127
  console.error('[a2a] Error handling incoming message:', err);
121
128
  });
@@ -181,16 +188,27 @@ export class A2AConnectionManager {
181
188
  */
182
189
  sendResponseViaGopherHole(_targetAgentId, taskId, text, _contextId) {
183
190
  if (!this.gopherhole || !this.connected) {
184
- console.warn('[a2a] Cannot send response - GopherHole not connected');
191
+ console.error('[a2a] Cannot send response - GopherHole not connected');
192
+ return;
193
+ }
194
+ // Validate taskId
195
+ if (!taskId) {
196
+ console.error('[a2a] Cannot respond - taskId is null/undefined!');
197
+ return;
198
+ }
199
+ if (taskId.startsWith('gph-')) {
200
+ console.error(`[a2a] Cannot respond - taskId "${taskId}" is a fallback ID (not a real task). Response will be lost!`);
185
201
  return;
186
202
  }
187
- console.log(`[a2a] Responding to taskId=${taskId}: ${text.slice(0, 100)}...`);
203
+ console.log(`[a2a] Responding to taskId=${taskId}: "${text.slice(0, 200)}..." (total ${text.length} chars)`);
188
204
  try {
189
205
  // Use SDK's respond method to complete the task
190
206
  this.gopherhole.respond(taskId, text);
207
+ console.log(`[a2a] respond() called successfully for taskId=${taskId}`);
191
208
  }
192
209
  catch (err) {
193
210
  console.error('[a2a] Failed to send response:', err.message);
211
+ console.error('[a2a] Error details:', err);
194
212
  }
195
213
  }
196
214
  /**
@@ -160,15 +160,23 @@ function handleChatEvent(payload) {
160
160
  // Not a chat we're tracking
161
161
  return;
162
162
  }
163
- console.log(`[a2a] Chat event: runId=${payload.runId}, state=${payload.state}, seq=${payload.seq}, content=${JSON.stringify(payload.message?.content)?.slice(0, 200)}`);
163
+ // Detailed logging for debugging relay issues
164
+ console.log(`[a2a] Chat event: runId=${payload.runId}, state=${payload.state}, seq=${payload.seq}, role=${payload.message?.role}`);
165
+ if (payload.state === 'delta' || payload.state === 'final') {
166
+ console.log(`[a2a] Chat content (${payload.state}): ${JSON.stringify(payload.message?.content)?.slice(0, 300)}`);
167
+ }
164
168
  if (payload.state === 'delta') {
165
169
  // Each delta contains the full message so far, not incremental
166
170
  if (payload.message?.role === 'assistant' && payload.message?.content) {
167
171
  const text = extractTextContent(payload.message.content);
168
172
  if (text) {
169
173
  pending.latestText = text;
174
+ console.log(`[a2a] Updated latestText (delta): "${text.slice(0, 100)}..." (len=${text.length})`);
170
175
  }
171
176
  }
177
+ else {
178
+ console.log(`[a2a] Skipping delta - role=${payload.message?.role}, hasContent=${!!payload.message?.content}`);
179
+ }
172
180
  }
173
181
  else if (payload.state === 'final') {
174
182
  // Final message - resolve with latest text
@@ -179,14 +187,16 @@ function handleChatEvent(payload) {
179
187
  const text = extractTextContent(payload.message.content);
180
188
  if (text) {
181
189
  pending.latestText = text;
190
+ console.log(`[a2a] Updated latestText (final): "${text.slice(0, 100)}..." (len=${text.length})`);
182
191
  }
183
192
  }
184
- console.log(`[a2a] Chat complete: ${pending.latestText.slice(0, 100)}...`);
193
+ console.log(`[a2a] Chat complete - resolving with: "${pending.latestText.slice(0, 150)}..." (total ${pending.latestText.length} chars)`);
185
194
  pending.resolve({ text: pending.latestText });
186
195
  }
187
196
  else if (payload.state === 'error' || payload.state === 'aborted') {
188
197
  clearTimeout(pending.timeout);
189
198
  pendingChats.delete(payload.runId);
199
+ console.error(`[a2a] Chat ${payload.state}: ${payload.errorMessage}`);
190
200
  pending.reject(new Error(payload.errorMessage || `Chat ${payload.state}`));
191
201
  }
192
202
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A2A Plugin Logger
3
+ * Writes to both console and a dedicated log file for dashboard viewing
4
+ */
5
+ export interface A2ALogEntry {
6
+ timestamp: string;
7
+ level: 'info' | 'warn' | 'error' | 'debug';
8
+ event: string;
9
+ taskId?: string;
10
+ from?: string;
11
+ to?: string;
12
+ message?: string;
13
+ data?: Record<string, unknown>;
14
+ }
15
+ export declare const a2aLog: {
16
+ info(event: string, message: string, data?: Record<string, unknown>): void;
17
+ warn(event: string, message: string, data?: Record<string, unknown>): void;
18
+ error(event: string, message: string, data?: Record<string, unknown>): void;
19
+ messageReceived(from: string, taskId: string | undefined, text: string): void;
20
+ messageProcessing(taskId: string | undefined, sessionKey: string): void;
21
+ responseCaptured(taskId: string | undefined, text: string): void;
22
+ responseSent(taskId: string, to: string, success: boolean): void;
23
+ };
24
+ export declare const A2A_LOG_FILE_PATH: string;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A2A Plugin Logger
3
+ * Writes to both console and a dedicated log file for dashboard viewing
4
+ */
5
+ import { appendFileSync, mkdirSync, existsSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+ const LOG_DIR = join(homedir(), '.clawdbot', 'logs');
9
+ const A2A_LOG_FILE = join(LOG_DIR, 'a2a.log');
10
+ // Ensure log directory exists
11
+ try {
12
+ if (!existsSync(LOG_DIR)) {
13
+ mkdirSync(LOG_DIR, { recursive: true });
14
+ }
15
+ }
16
+ catch {
17
+ // Ignore if we can't create the directory
18
+ }
19
+ function writeLog(entry) {
20
+ const line = JSON.stringify(entry) + '\n';
21
+ // Console output (colored)
22
+ const prefix = `[a2a:${entry.event}]`;
23
+ const msg = entry.message || '';
24
+ switch (entry.level) {
25
+ case 'error':
26
+ console.error(prefix, msg, entry.data || '');
27
+ break;
28
+ case 'warn':
29
+ console.warn(prefix, msg, entry.data || '');
30
+ break;
31
+ default:
32
+ console.log(prefix, msg, entry.data || '');
33
+ }
34
+ // File output (JSON lines)
35
+ try {
36
+ appendFileSync(A2A_LOG_FILE, line);
37
+ }
38
+ catch {
39
+ // Ignore file write errors
40
+ }
41
+ }
42
+ export const a2aLog = {
43
+ info(event, message, data) {
44
+ writeLog({ timestamp: new Date().toISOString(), level: 'info', event, message, data });
45
+ },
46
+ warn(event, message, data) {
47
+ writeLog({ timestamp: new Date().toISOString(), level: 'warn', event, message, data });
48
+ },
49
+ error(event, message, data) {
50
+ writeLog({ timestamp: new Date().toISOString(), level: 'error', event, message, data });
51
+ },
52
+ // Structured event logging for dashboard
53
+ messageReceived(from, taskId, text) {
54
+ writeLog({
55
+ timestamp: new Date().toISOString(),
56
+ level: 'info',
57
+ event: 'message_received',
58
+ from,
59
+ taskId,
60
+ message: text.slice(0, 200),
61
+ data: { textLength: text.length },
62
+ });
63
+ },
64
+ messageProcessing(taskId, sessionKey) {
65
+ writeLog({
66
+ timestamp: new Date().toISOString(),
67
+ level: 'info',
68
+ event: 'message_processing',
69
+ taskId,
70
+ data: { sessionKey },
71
+ });
72
+ },
73
+ responseCaptured(taskId, text) {
74
+ writeLog({
75
+ timestamp: new Date().toISOString(),
76
+ level: 'info',
77
+ event: 'response_captured',
78
+ taskId,
79
+ message: text.slice(0, 200),
80
+ data: { textLength: text.length },
81
+ });
82
+ },
83
+ responseSent(taskId, to, success) {
84
+ writeLog({
85
+ timestamp: new Date().toISOString(),
86
+ level: success ? 'info' : 'error',
87
+ event: 'response_sent',
88
+ taskId,
89
+ to,
90
+ data: { success },
91
+ });
92
+ },
93
+ };
94
+ export const A2A_LOG_FILE_PATH = A2A_LOG_FILE;
package/index.ts CHANGED
@@ -72,8 +72,13 @@ const plugin = {
72
72
  }
73
73
 
74
74
  if (action === 'list') {
75
- const agents = manager.listAgents();
76
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'ok', agents }) }] };
75
+ const hubStatus = manager.listAgents();
76
+ const availableAgents = await manager.listAvailableAgents();
77
+ return { content: [{ type: 'text', text: JSON.stringify({
78
+ status: 'ok',
79
+ connected: hubStatus.some(h => h.connected),
80
+ agents: availableAgents
81
+ }) }] };
77
82
  }
78
83
 
79
84
  if (action === 'send') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gopherhole_openclaw_a2a",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "GopherHole A2A plugin for OpenClaw - connect your AI agent to the GopherHole network",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/channel.ts CHANGED
@@ -11,6 +11,7 @@ function normalizeAccountId(id?: string): string {
11
11
 
12
12
  import { A2AConnectionManager } from './connection.js';
13
13
  import { sendChatMessage, connectToGateway, disconnectFromGateway } from './gateway-client.js';
14
+ import { a2aLog } from './logger.js';
14
15
  import type {
15
16
  A2AMessage,
16
17
  A2AChannelConfig,
@@ -293,28 +294,54 @@ export const a2aPlugin: ChannelPlugin<ResolvedA2AAccount> = {
293
294
 
294
295
  if (!text) return;
295
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
+
296
305
  // Route to OpenClaw's reply pipeline via gateway JSON-RPC
297
306
  try {
298
- ctx.log?.info(`[a2a] Routing message from ${message.from}: "${text.slice(0, 100)}..."`);
299
-
300
307
  // Use chat.send to route the message through the agent
301
308
  // Session key format: agent:<agentId>:<channel>:<chatId>
302
309
  const sessionKey = `agent:main:a2a:${message.from}`;
303
- const response = await sendChatMessage(sessionKey, text);
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);
304
316
 
305
- ctx.log?.info(`[a2a] chat.send returned: ${response ? `text=${response.text?.slice(0, 50)}...` : 'null'}`);
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
+ }
306
323
 
307
324
  // Send response back to the agent via GopherHole
308
- if (response?.text) {
309
- connectionManager?.sendResponseViaGopherHole(
325
+ if (response?.text && response.text.trim()) {
326
+ const sent = connectionManager?.sendResponseViaGopherHole(
310
327
  message.from,
311
328
  message.taskId,
312
329
  response.text,
313
330
  message.contextId
314
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);
315
342
  }
316
343
  } catch (err) {
317
- ctx.log?.error(`[a2a] Error handling message:`, err);
344
+ a2aLog.error('handler_error', (err as Error).message, { taskId: message.taskId, stack: (err as Error).stack });
318
345
  connectionManager?.sendResponseViaGopherHole(
319
346
  message.from,
320
347
  message.taskId,
package/src/connection.ts CHANGED
@@ -122,6 +122,13 @@ export class A2AConnectionManager {
122
122
  if (!this.messageHandler) return;
123
123
 
124
124
  console.log(`[a2a] Received message from ${message.from}, taskId=${message.taskId}`);
125
+ console.log(`[a2a] Raw message payload:`, JSON.stringify(message.payload, null, 2).slice(0, 500));
126
+
127
+ // Validate taskId - critical for response routing
128
+ if (!message.taskId) {
129
+ console.error(`[a2a] WARNING: No taskId in incoming message! Response relay will fail.`);
130
+ console.error(`[a2a] Full message object:`, JSON.stringify(message, null, 2));
131
+ }
125
132
 
126
133
  // Convert SDK message to our A2AMessage format
127
134
  const a2aMsg: A2AMessage = {
@@ -138,6 +145,8 @@ export class A2AConnectionManager {
138
145
  },
139
146
  };
140
147
 
148
+ console.log(`[a2a] Dispatching to messageHandler with taskId=${a2aMsg.taskId}`);
149
+
141
150
  this.messageHandler('gopherhole', a2aMsg).catch((err) => {
142
151
  console.error('[a2a] Error handling incoming message:', err);
143
152
  });
@@ -230,17 +239,30 @@ export class A2AConnectionManager {
230
239
  _contextId?: string
231
240
  ): void {
232
241
  if (!this.gopherhole || !this.connected) {
233
- console.warn('[a2a] Cannot send response - GopherHole not connected');
242
+ console.error('[a2a] Cannot send response - GopherHole not connected');
243
+ return;
244
+ }
245
+
246
+ // Validate taskId
247
+ if (!taskId) {
248
+ console.error('[a2a] Cannot respond - taskId is null/undefined!');
249
+ return;
250
+ }
251
+
252
+ if (taskId.startsWith('gph-')) {
253
+ console.error(`[a2a] Cannot respond - taskId "${taskId}" is a fallback ID (not a real task). Response will be lost!`);
234
254
  return;
235
255
  }
236
256
 
237
- console.log(`[a2a] Responding to taskId=${taskId}: ${text.slice(0, 100)}...`);
257
+ console.log(`[a2a] Responding to taskId=${taskId}: "${text.slice(0, 200)}..." (total ${text.length} chars)`);
238
258
 
239
259
  try {
240
260
  // Use SDK's respond method to complete the task
241
261
  this.gopherhole.respond(taskId, text);
262
+ console.log(`[a2a] respond() called successfully for taskId=${taskId}`);
242
263
  } catch (err) {
243
264
  console.error('[a2a] Failed to send response:', (err as Error).message);
265
+ console.error('[a2a] Error details:', err);
244
266
  }
245
267
  }
246
268
 
@@ -201,7 +201,12 @@ function handleChatEvent(payload: {
201
201
  return;
202
202
  }
203
203
 
204
- console.log(`[a2a] Chat event: runId=${payload.runId}, state=${payload.state}, seq=${payload.seq}, content=${JSON.stringify(payload.message?.content)?.slice(0, 200)}`);
204
+ // Detailed logging for debugging relay issues
205
+ console.log(`[a2a] Chat event: runId=${payload.runId}, state=${payload.state}, seq=${payload.seq}, role=${payload.message?.role}`);
206
+
207
+ if (payload.state === 'delta' || payload.state === 'final') {
208
+ console.log(`[a2a] Chat content (${payload.state}): ${JSON.stringify(payload.message?.content)?.slice(0, 300)}`);
209
+ }
205
210
 
206
211
  if (payload.state === 'delta') {
207
212
  // Each delta contains the full message so far, not incremental
@@ -209,7 +214,10 @@ function handleChatEvent(payload: {
209
214
  const text = extractTextContent(payload.message.content);
210
215
  if (text) {
211
216
  pending.latestText = text;
217
+ console.log(`[a2a] Updated latestText (delta): "${text.slice(0, 100)}..." (len=${text.length})`);
212
218
  }
219
+ } else {
220
+ console.log(`[a2a] Skipping delta - role=${payload.message?.role}, hasContent=${!!payload.message?.content}`);
213
221
  }
214
222
  } else if (payload.state === 'final') {
215
223
  // Final message - resolve with latest text
@@ -221,14 +229,16 @@ function handleChatEvent(payload: {
221
229
  const text = extractTextContent(payload.message.content);
222
230
  if (text) {
223
231
  pending.latestText = text;
232
+ console.log(`[a2a] Updated latestText (final): "${text.slice(0, 100)}..." (len=${text.length})`);
224
233
  }
225
234
  }
226
235
 
227
- console.log(`[a2a] Chat complete: ${pending.latestText.slice(0, 100)}...`);
236
+ console.log(`[a2a] Chat complete - resolving with: "${pending.latestText.slice(0, 150)}..." (total ${pending.latestText.length} chars)`);
228
237
  pending.resolve({ text: pending.latestText });
229
238
  } else if (payload.state === 'error' || payload.state === 'aborted') {
230
239
  clearTimeout(pending.timeout);
231
240
  pendingChats.delete(payload.runId);
241
+ console.error(`[a2a] Chat ${payload.state}: ${payload.errorMessage}`);
232
242
  pending.reject(new Error(payload.errorMessage || `Chat ${payload.state}`));
233
243
  }
234
244
  }
package/src/logger.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * A2A Plugin Logger
3
+ * Writes to both console and a dedicated log file for dashboard viewing
4
+ */
5
+
6
+ import { appendFileSync, mkdirSync, existsSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { homedir } from 'os';
9
+
10
+ const LOG_DIR = join(homedir(), '.clawdbot', 'logs');
11
+ const A2A_LOG_FILE = join(LOG_DIR, 'a2a.log');
12
+
13
+ // Ensure log directory exists
14
+ try {
15
+ if (!existsSync(LOG_DIR)) {
16
+ mkdirSync(LOG_DIR, { recursive: true });
17
+ }
18
+ } catch {
19
+ // Ignore if we can't create the directory
20
+ }
21
+
22
+ export interface A2ALogEntry {
23
+ timestamp: string;
24
+ level: 'info' | 'warn' | 'error' | 'debug';
25
+ event: string;
26
+ taskId?: string;
27
+ from?: string;
28
+ to?: string;
29
+ message?: string;
30
+ data?: Record<string, unknown>;
31
+ }
32
+
33
+ function writeLog(entry: A2ALogEntry): void {
34
+ const line = JSON.stringify(entry) + '\n';
35
+
36
+ // Console output (colored)
37
+ const prefix = `[a2a:${entry.event}]`;
38
+ const msg = entry.message || '';
39
+
40
+ switch (entry.level) {
41
+ case 'error':
42
+ console.error(prefix, msg, entry.data || '');
43
+ break;
44
+ case 'warn':
45
+ console.warn(prefix, msg, entry.data || '');
46
+ break;
47
+ default:
48
+ console.log(prefix, msg, entry.data || '');
49
+ }
50
+
51
+ // File output (JSON lines)
52
+ try {
53
+ appendFileSync(A2A_LOG_FILE, line);
54
+ } catch {
55
+ // Ignore file write errors
56
+ }
57
+ }
58
+
59
+ export const a2aLog = {
60
+ info(event: string, message: string, data?: Record<string, unknown>): void {
61
+ writeLog({ timestamp: new Date().toISOString(), level: 'info', event, message, data });
62
+ },
63
+
64
+ warn(event: string, message: string, data?: Record<string, unknown>): void {
65
+ writeLog({ timestamp: new Date().toISOString(), level: 'warn', event, message, data });
66
+ },
67
+
68
+ error(event: string, message: string, data?: Record<string, unknown>): void {
69
+ writeLog({ timestamp: new Date().toISOString(), level: 'error', event, message, data });
70
+ },
71
+
72
+ // Structured event logging for dashboard
73
+ messageReceived(from: string, taskId: string | undefined, text: string): void {
74
+ writeLog({
75
+ timestamp: new Date().toISOString(),
76
+ level: 'info',
77
+ event: 'message_received',
78
+ from,
79
+ taskId,
80
+ message: text.slice(0, 200),
81
+ data: { textLength: text.length },
82
+ });
83
+ },
84
+
85
+ messageProcessing(taskId: string | undefined, sessionKey: string): void {
86
+ writeLog({
87
+ timestamp: new Date().toISOString(),
88
+ level: 'info',
89
+ event: 'message_processing',
90
+ taskId,
91
+ data: { sessionKey },
92
+ });
93
+ },
94
+
95
+ responseCaptured(taskId: string | undefined, text: string): void {
96
+ writeLog({
97
+ timestamp: new Date().toISOString(),
98
+ level: 'info',
99
+ event: 'response_captured',
100
+ taskId,
101
+ message: text.slice(0, 200),
102
+ data: { textLength: text.length },
103
+ });
104
+ },
105
+
106
+ responseSent(taskId: string, to: string, success: boolean): void {
107
+ writeLog({
108
+ timestamp: new Date().toISOString(),
109
+ level: success ? 'info' : 'error',
110
+ event: 'response_sent',
111
+ taskId,
112
+ to,
113
+ data: { success },
114
+ });
115
+ },
116
+ };
117
+
118
+ export const A2A_LOG_FILE_PATH = A2A_LOG_FILE;