Spaces:
Running
Running
feat: add remote agent monitoring and A2A activity tracking to proxy
Browse files- Poll remote agents (Adam/Eve) via REMOTE_AGENTS env var
- Intercept /agents endpoint to merge OpenClaw + remote agent states
- Track A2A POST requests to show "writing" state during communication
- Remote agent states mapped to Office areas (breakroom/writing/error)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- scripts/a2a-proxy.cjs +145 -0
scripts/a2a-proxy.cjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
| 5 |
* /.well-known/* β A2A gateway (port 18800)
|
| 6 |
* /a2a/* β A2A gateway (port 18800)
|
| 7 |
* /api/state β local state JSON (for Office frontend polling)
|
|
|
|
| 8 |
* everything else β OpenClaw (port 7861)
|
| 9 |
*/
|
| 10 |
'use strict';
|
|
@@ -17,6 +18,16 @@ const OPENCLAW_PORT = 7861;
|
|
| 17 |
const A2A_PORT = 18800;
|
| 18 |
const AGENT_NAME = process.env.AGENT_NAME || 'Agent';
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
let currentState = {
|
| 21 |
state: 'syncing',
|
| 22 |
detail: `${AGENT_NAME} is starting...`,
|
|
@@ -24,8 +35,90 @@ let currentState = {
|
|
| 24 |
updated_at: new Date().toISOString()
|
| 25 |
};
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
// Poll OpenClaw health to track state
|
| 28 |
async function pollOpenClawHealth() {
|
|
|
|
|
|
|
| 29 |
try {
|
| 30 |
const controller = new AbortController();
|
| 31 |
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
@@ -52,6 +145,34 @@ async function pollOpenClawHealth() {
|
|
| 52 |
setInterval(pollOpenClawHealth, 5000);
|
| 53 |
pollOpenClawHealth();
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
function proxyRequest(req, res, targetPort) {
|
| 56 |
const options = {
|
| 57 |
hostname: '127.0.0.1',
|
|
@@ -81,6 +202,11 @@ const server = http.createServer((req, res) => {
|
|
| 81 |
|
| 82 |
// A2A routes β A2A gateway
|
| 83 |
if (pathname.startsWith('/.well-known/') || pathname.startsWith('/a2a/')) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
return proxyRequest(req, res, A2A_PORT);
|
| 85 |
}
|
| 86 |
|
|
@@ -93,6 +219,25 @@ const server = http.createServer((req, res) => {
|
|
| 93 |
return res.end(JSON.stringify(currentState));
|
| 94 |
}
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
// Everything else β OpenClaw
|
| 97 |
proxyRequest(req, res, OPENCLAW_PORT);
|
| 98 |
});
|
|
|
|
| 5 |
* /.well-known/* β A2A gateway (port 18800)
|
| 6 |
* /a2a/* β A2A gateway (port 18800)
|
| 7 |
* /api/state β local state JSON (for Office frontend polling)
|
| 8 |
+
* /agents β merged agent list (OpenClaw + remote agents)
|
| 9 |
* everything else β OpenClaw (port 7861)
|
| 10 |
*/
|
| 11 |
'use strict';
|
|
|
|
| 18 |
const A2A_PORT = 18800;
|
| 19 |
const AGENT_NAME = process.env.AGENT_NAME || 'Agent';
|
| 20 |
|
| 21 |
+
// Remote agents to monitor (comma-separated URLs)
|
| 22 |
+
// e.g. REMOTE_AGENTS=adam|Adam|https://tao-shen-huggingclaw-adam.hf.space,eve|Eve|https://tao-shen-huggingclaw-eve.hf.space
|
| 23 |
+
const REMOTE_AGENTS_RAW = process.env.REMOTE_AGENTS || '';
|
| 24 |
+
const remoteAgents = REMOTE_AGENTS_RAW
|
| 25 |
+
? REMOTE_AGENTS_RAW.split(',').map(entry => {
|
| 26 |
+
const [id, name, baseUrl] = entry.trim().split('|');
|
| 27 |
+
return { id, name, baseUrl };
|
| 28 |
+
}).filter(a => a.id && a.name && a.baseUrl)
|
| 29 |
+
: [];
|
| 30 |
+
|
| 31 |
let currentState = {
|
| 32 |
state: 'syncing',
|
| 33 |
detail: `${AGENT_NAME} is starting...`,
|
|
|
|
| 35 |
updated_at: new Date().toISOString()
|
| 36 |
};
|
| 37 |
|
| 38 |
+
// Track A2A activity β when an A2A message is being processed,
|
| 39 |
+
// temporarily switch state to 'writing' so frontends can see it
|
| 40 |
+
let a2aActiveRequests = 0;
|
| 41 |
+
let a2aIdleTimer = null;
|
| 42 |
+
const A2A_IDLE_DELAY = 8000; // stay "writing" for 8s after last A2A request ends
|
| 43 |
+
|
| 44 |
+
function markA2AActive() {
|
| 45 |
+
a2aActiveRequests++;
|
| 46 |
+
if (a2aIdleTimer) { clearTimeout(a2aIdleTimer); a2aIdleTimer = null; }
|
| 47 |
+
currentState = {
|
| 48 |
+
state: 'writing',
|
| 49 |
+
detail: `${AGENT_NAME} is communicating...`,
|
| 50 |
+
progress: 100,
|
| 51 |
+
updated_at: new Date().toISOString()
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function markA2ADone() {
|
| 56 |
+
a2aActiveRequests = Math.max(0, a2aActiveRequests - 1);
|
| 57 |
+
if (a2aActiveRequests === 0) {
|
| 58 |
+
if (a2aIdleTimer) clearTimeout(a2aIdleTimer);
|
| 59 |
+
a2aIdleTimer = setTimeout(() => {
|
| 60 |
+
a2aIdleTimer = null;
|
| 61 |
+
pollOpenClawHealth();
|
| 62 |
+
}, A2A_IDLE_DELAY);
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// Remote agent states (polled periodically)
|
| 67 |
+
const remoteAgentStates = new Map();
|
| 68 |
+
|
| 69 |
+
async function pollRemoteAgent(agent) {
|
| 70 |
+
try {
|
| 71 |
+
const controller = new AbortController();
|
| 72 |
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
| 73 |
+
const resp = await fetch(`${agent.baseUrl}/api/state`, {
|
| 74 |
+
signal: controller.signal
|
| 75 |
+
});
|
| 76 |
+
clearTimeout(timeout);
|
| 77 |
+
if (resp.ok) {
|
| 78 |
+
const data = await resp.json();
|
| 79 |
+
remoteAgentStates.set(agent.id, {
|
| 80 |
+
agentId: agent.id,
|
| 81 |
+
name: agent.name,
|
| 82 |
+
state: data.state || 'idle',
|
| 83 |
+
detail: data.detail || '',
|
| 84 |
+
area: (data.state === 'idle') ? 'breakroom'
|
| 85 |
+
: (data.state === 'error') ? 'error'
|
| 86 |
+
: 'writing',
|
| 87 |
+
authStatus: 'approved',
|
| 88 |
+
updated_at: data.updated_at
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
} catch (_) {
|
| 92 |
+
// Keep last known state or mark as offline
|
| 93 |
+
if (!remoteAgentStates.has(agent.id)) {
|
| 94 |
+
remoteAgentStates.set(agent.id, {
|
| 95 |
+
agentId: agent.id,
|
| 96 |
+
name: agent.name,
|
| 97 |
+
state: 'syncing',
|
| 98 |
+
detail: `${agent.name} is starting...`,
|
| 99 |
+
area: 'door',
|
| 100 |
+
authStatus: 'approved'
|
| 101 |
+
});
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function pollAllRemoteAgents() {
|
| 107 |
+
for (const agent of remoteAgents) {
|
| 108 |
+
pollRemoteAgent(agent);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
if (remoteAgents.length > 0) {
|
| 113 |
+
setInterval(pollAllRemoteAgents, 5000);
|
| 114 |
+
pollAllRemoteAgents();
|
| 115 |
+
console.log(`[a2a-proxy] Monitoring ${remoteAgents.length} remote agent(s): ${remoteAgents.map(a => a.name).join(', ')}`);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
// Poll OpenClaw health to track state
|
| 119 |
async function pollOpenClawHealth() {
|
| 120 |
+
// Don't overwrite active A2A state
|
| 121 |
+
if (a2aActiveRequests > 0 || a2aIdleTimer) return;
|
| 122 |
try {
|
| 123 |
const controller = new AbortController();
|
| 124 |
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
|
|
| 145 |
setInterval(pollOpenClawHealth, 5000);
|
| 146 |
pollOpenClawHealth();
|
| 147 |
|
| 148 |
+
// Fetch agents from OpenClaw and merge with remote agents
|
| 149 |
+
async function getMergedAgents() {
|
| 150 |
+
let openClawAgents = [];
|
| 151 |
+
try {
|
| 152 |
+
const controller = new AbortController();
|
| 153 |
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
| 154 |
+
const resp = await fetch(`http://127.0.0.1:${OPENCLAW_PORT}/agents`, {
|
| 155 |
+
signal: controller.signal
|
| 156 |
+
});
|
| 157 |
+
clearTimeout(timeout);
|
| 158 |
+
if (resp.ok) {
|
| 159 |
+
openClawAgents = await resp.json();
|
| 160 |
+
if (!Array.isArray(openClawAgents)) openClawAgents = [];
|
| 161 |
+
}
|
| 162 |
+
} catch (_) {}
|
| 163 |
+
|
| 164 |
+
// Merge: OpenClaw agents + remote agents (deduplicate by agentId)
|
| 165 |
+
const existingIds = new Set(openClawAgents.map(a => a.agentId));
|
| 166 |
+
const merged = [...openClawAgents];
|
| 167 |
+
let slotIndex = openClawAgents.length;
|
| 168 |
+
for (const [id, agentState] of remoteAgentStates) {
|
| 169 |
+
if (!existingIds.has(id)) {
|
| 170 |
+
merged.push({ ...agentState, _slotIndex: slotIndex++ });
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
return merged;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
function proxyRequest(req, res, targetPort) {
|
| 177 |
const options = {
|
| 178 |
hostname: '127.0.0.1',
|
|
|
|
| 202 |
|
| 203 |
// A2A routes β A2A gateway
|
| 204 |
if (pathname.startsWith('/.well-known/') || pathname.startsWith('/a2a/')) {
|
| 205 |
+
// Track POST requests (message/send) as active communication
|
| 206 |
+
if (req.method === 'POST') {
|
| 207 |
+
markA2AActive();
|
| 208 |
+
res.on('finish', markA2ADone);
|
| 209 |
+
}
|
| 210 |
return proxyRequest(req, res, A2A_PORT);
|
| 211 |
}
|
| 212 |
|
|
|
|
| 219 |
return res.end(JSON.stringify(currentState));
|
| 220 |
}
|
| 221 |
|
| 222 |
+
// Agents endpoint β merge OpenClaw agents with remote agents
|
| 223 |
+
if (pathname === '/agents' && req.method === 'GET') {
|
| 224 |
+
getMergedAgents().then(agents => {
|
| 225 |
+
res.writeHead(200, {
|
| 226 |
+
'Content-Type': 'application/json',
|
| 227 |
+
'Access-Control-Allow-Origin': '*'
|
| 228 |
+
});
|
| 229 |
+
res.end(JSON.stringify(agents));
|
| 230 |
+
}).catch(() => {
|
| 231 |
+
// Fallback: just return remote agents
|
| 232 |
+
res.writeHead(200, {
|
| 233 |
+
'Content-Type': 'application/json',
|
| 234 |
+
'Access-Control-Allow-Origin': '*'
|
| 235 |
+
});
|
| 236 |
+
res.end(JSON.stringify([...remoteAgentStates.values()]));
|
| 237 |
+
});
|
| 238 |
+
return;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
// Everything else β OpenClaw
|
| 242 |
proxyRequest(req, res, OPENCLAW_PORT);
|
| 243 |
});
|