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.
- package/A2A-FIX-GUIDE.md +122 -0
- package/dist/index.js +7 -2
- package/dist/src/channel.js +27 -6
- package/dist/src/connection.js +20 -2
- package/dist/src/gateway-client.js +12 -2
- package/dist/src/logger.d.ts +24 -0
- package/dist/src/logger.js +94 -0
- package/index.ts +7 -2
- package/package.json +1 -1
- package/src/channel.ts +34 -7
- package/src/connection.ts +24 -2
- package/src/gateway-client.ts +12 -2
- package/src/logger.ts +118 -0
package/A2A-FIX-GUIDE.md
ADDED
|
@@ -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
|
|
59
|
-
|
|
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)) {
|
package/dist/src/channel.js
CHANGED
|
@@ -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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/connection.js
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
76
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
package/src/gateway-client.ts
CHANGED
|
@@ -201,7 +201,12 @@ function handleChatEvent(payload: {
|
|
|
201
201
|
return;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
|
|
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,
|
|
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;
|