@hamp10/agentforge 0.1.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/bin/agentforge.js +558 -0
- package/package.json +22 -0
- package/src/HampAgentCLI.js +125 -0
- package/src/OllamaAgent.js +415 -0
- package/src/OpenClawCLI.js +1520 -0
- package/src/hampagent/browser.js +185 -0
- package/src/hampagent/runner.js +277 -0
- package/src/hampagent/sessions.js +62 -0
- package/src/hampagent/tools.js +298 -0
- package/src/preview-server.js +260 -0
- package/src/worker.js +1791 -0
- package/templates/agent/AGENTFORGE.md +348 -0
- package/templates/agent/AGENTS.md +212 -0
- package/templates/agent/SOUL.md +36 -0
- package/templates/agent/TOOLS.md +40 -0
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import treeKill from 'tree-kill';
|
|
7
|
+
|
|
8
|
+
// Canary configuration
|
|
9
|
+
const CANARY_ENDPOINT = process.env.CANARY_ENDPOINT || 'https://canary.bot';
|
|
10
|
+
const CANARY_PARENT_API_KEY = process.env.CANARY_PARENT_API_KEY; // Optional: inherit ownership
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Controls OpenClaw agents via CLI instead of WebSocket Gateway
|
|
14
|
+
*/
|
|
15
|
+
export class OpenClawCLI extends EventEmitter {
|
|
16
|
+
static _findBin() {
|
|
17
|
+
const home = process.env.HOME || homedir();
|
|
18
|
+
const candidates = [
|
|
19
|
+
path.join(home, '.npm-global/bin/openclaw'),
|
|
20
|
+
'/usr/local/bin/openclaw',
|
|
21
|
+
'/opt/homebrew/bin/openclaw',
|
|
22
|
+
];
|
|
23
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static isAvailable() {
|
|
28
|
+
return !!OpenClawCLI._findBin();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
constructor() {
|
|
32
|
+
super();
|
|
33
|
+
this.activeAgents = new Map();
|
|
34
|
+
// Full absolute path to the openclaw script
|
|
35
|
+
this.bin = OpenClawCLI._findBin() || '/usr/local/bin/openclaw';
|
|
36
|
+
// Read valid Anthropic API key from local auth-profiles at startup
|
|
37
|
+
// This is passed as ANTHROPIC_API_KEY to every openclaw spawn, bypassing
|
|
38
|
+
// any broken auth-profiles.json on this machine (e.g. invalid default key)
|
|
39
|
+
this.anthropicApiKey = this._readAnthropicKey();
|
|
40
|
+
// OpenClaw Gateway streaming config — populated by worker.js on init
|
|
41
|
+
this.gatewayPort = null;
|
|
42
|
+
this.gatewayToken = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run an agent task via the OpenClaw Gateway's OpenAI-compatible streaming endpoint.
|
|
47
|
+
* Returns the full response text, emitting agent_output events in real-time for each token.
|
|
48
|
+
* Falls back to null if the gateway is not configured or the request fails.
|
|
49
|
+
*/
|
|
50
|
+
async _runAgentTaskStreaming(agentId, task, sessionId) {
|
|
51
|
+
if (!this.gatewayPort || !this.gatewayToken) return null;
|
|
52
|
+
const url = `http://127.0.0.1:${this.gatewayPort}/v1/chat/completions`;
|
|
53
|
+
const sessionKey = `agent:${agentId}:main`;
|
|
54
|
+
let fullText = '';
|
|
55
|
+
|
|
56
|
+
// Friendly names for tool calls shown as live messages to the user
|
|
57
|
+
const toolLabels = {
|
|
58
|
+
read: '📄 Reading file', write: '✏️ Writing file', edit: '✏️ Editing file',
|
|
59
|
+
bash: '⚡ Running command', exec: '⚡ Running command', process: '⚡ Running process',
|
|
60
|
+
web_search: '🔍 Searching the web', web_fetch: '🌐 Fetching page',
|
|
61
|
+
browser: '🌐 Browsing', image: '🖼️ Analyzing image',
|
|
62
|
+
memory_search: '🧠 Searching memory', memory_get: '🧠 Reading memory',
|
|
63
|
+
sessions_spawn: '🤖 Spawning sub-agent', sessions_send: '📤 Sending to agent',
|
|
64
|
+
tts: '🔊 Generating speech', canvas: '🎨 Updating canvas',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(url, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'Authorization': `Bearer ${this.gatewayToken}`,
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
// Target the agent's main session so history is preserved
|
|
74
|
+
'x-openclaw-agent-id': agentId,
|
|
75
|
+
'x-openclaw-session-key': sessionKey,
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
model: `openclaw:${agentId}`,
|
|
79
|
+
messages: [{ role: 'user', content: task }],
|
|
80
|
+
stream: true,
|
|
81
|
+
}),
|
|
82
|
+
signal: AbortSignal.timeout(600_000), // 10 min max
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
console.warn(`[${agentId}] ⚠️ Streaming HTTP ${res.status} — falling back to subprocess`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Parse SSE stream — emit text tokens AND tool call activity
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
let buffer = '';
|
|
92
|
+
const seenToolCallIds = new Set(); // avoid duplicate tool_activity for same call_id
|
|
93
|
+
const pendingToolCalls = new Map(); // index -> { id, name, args }
|
|
94
|
+
|
|
95
|
+
for await (const rawChunk of res.body) {
|
|
96
|
+
buffer += decoder.decode(rawChunk, { stream: true });
|
|
97
|
+
const lines = buffer.split('\n');
|
|
98
|
+
buffer = lines.pop() ?? ''; // keep incomplete line
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
if (!trimmed.startsWith('data:')) continue;
|
|
102
|
+
const json = trimmed.slice(5).trim();
|
|
103
|
+
if (json === '[DONE]') break;
|
|
104
|
+
try {
|
|
105
|
+
const chunk = JSON.parse(json);
|
|
106
|
+
const choice = chunk?.choices?.[0];
|
|
107
|
+
if (!choice) continue;
|
|
108
|
+
|
|
109
|
+
// Text token — stream to dashboard
|
|
110
|
+
const delta = choice.delta?.content;
|
|
111
|
+
if (delta) {
|
|
112
|
+
fullText += delta;
|
|
113
|
+
this.emit('agent_output', { agentId, output: delta });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Tool call delta — detect new tool calls and emit activity events
|
|
117
|
+
const toolCallDeltas = choice.delta?.tool_calls;
|
|
118
|
+
if (toolCallDeltas) {
|
|
119
|
+
for (const tc of toolCallDeltas) {
|
|
120
|
+
const idx = tc.index ?? 0;
|
|
121
|
+
if (!pendingToolCalls.has(idx)) {
|
|
122
|
+
pendingToolCalls.set(idx, { id: tc.id || '', name: tc.function?.name || '', args: '' });
|
|
123
|
+
} else {
|
|
124
|
+
const pending = pendingToolCalls.get(idx);
|
|
125
|
+
if (tc.function?.name && !pending.name) pending.name = tc.function.name;
|
|
126
|
+
if (tc.id && !pending.id) pending.id = tc.id;
|
|
127
|
+
if (tc.function?.arguments) pending.args += tc.function.arguments;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Emit tool_activity as soon as we have a name and haven't emitted for this call yet
|
|
131
|
+
const pending = pendingToolCalls.get(idx);
|
|
132
|
+
const callKey = pending.id || `${idx}-${pending.name}`;
|
|
133
|
+
if (pending.name && !seenToolCallIds.has(callKey)) {
|
|
134
|
+
seenToolCallIds.add(callKey);
|
|
135
|
+
const label = toolLabels[pending.name] || `🔧 ${pending.name}`;
|
|
136
|
+
console.log(`[${agentId}] 🛠️ Tool call: ${pending.name}`);
|
|
137
|
+
|
|
138
|
+
// Emit tool_activity so the typing indicator + thinking log update
|
|
139
|
+
this.emit('tool_activity', {
|
|
140
|
+
agentId,
|
|
141
|
+
event: 'tool_start',
|
|
142
|
+
tool: pending.name,
|
|
143
|
+
description: label,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Tool calls finishing — emit complete events
|
|
150
|
+
if (choice.finish_reason === 'tool_calls') {
|
|
151
|
+
for (const [, tc] of pendingToolCalls) {
|
|
152
|
+
if (tc.name) {
|
|
153
|
+
this.emit('tool_activity', { agentId, event: 'tool_end', tool: tc.name, description: `✓ ${tc.name}` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
pendingToolCalls.clear();
|
|
157
|
+
}
|
|
158
|
+
} catch { /* malformed chunk — skip */ }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Return an object so callers can distinguish "success with no text" from "request failed"
|
|
162
|
+
return { text: fullText, succeeded: true };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.warn(`[${agentId}] ⚠️ Streaming HTTP error: ${err.message} — falling back to subprocess`);
|
|
165
|
+
return null; // null = request failed, subprocess fallback needed
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Read the first valid Anthropic API key from local auth-profiles (skips :default) */
|
|
170
|
+
_readAnthropicKey() {
|
|
171
|
+
// 1. Env var override
|
|
172
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
173
|
+
console.log('🔑 Anthropic key loaded from ANTHROPIC_API_KEY env');
|
|
174
|
+
return process.env.ANTHROPIC_API_KEY;
|
|
175
|
+
}
|
|
176
|
+
// 2. ~/.agentforge/config.json anthropicToken field
|
|
177
|
+
try {
|
|
178
|
+
const cfg = path.join(homedir(), '.agentforge', 'config.json');
|
|
179
|
+
if (existsSync(cfg)) {
|
|
180
|
+
const d = JSON.parse(readFileSync(cfg, 'utf-8'));
|
|
181
|
+
if (d.anthropicToken) {
|
|
182
|
+
console.log('🔑 Anthropic key loaded from agentforge config');
|
|
183
|
+
return d.anthropicToken;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch {}
|
|
187
|
+
// 3. Scan agent auth-profiles
|
|
188
|
+
try {
|
|
189
|
+
const agentsDir = path.join(homedir(), '.openclaw', 'agents');
|
|
190
|
+
if (!existsSync(agentsDir)) return null;
|
|
191
|
+
for (const dir of readdirSync(agentsDir)) {
|
|
192
|
+
const authPath = path.join(agentsDir, dir, 'agent', 'auth-profiles.json');
|
|
193
|
+
if (!existsSync(authPath)) continue;
|
|
194
|
+
try {
|
|
195
|
+
const data = JSON.parse(readFileSync(authPath, 'utf-8'));
|
|
196
|
+
for (const [name, profile] of Object.entries(data?.profiles || {})) {
|
|
197
|
+
if (name !== 'anthropic:default' && profile?.provider === 'anthropic' && profile?.token) {
|
|
198
|
+
console.log(`🔑 Anthropic key loaded from profile: ${name}`);
|
|
199
|
+
return profile.token;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.warn('⚠️ Could not read auth-profiles:', e.message);
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Extract toolInput JSON from a verbose openclaw line.
|
|
212
|
+
* Handles brace-balanced extraction so nested objects parse correctly.
|
|
213
|
+
* Returns parsed object or null.
|
|
214
|
+
*/
|
|
215
|
+
extractToolInput(line) {
|
|
216
|
+
const idx = line.indexOf('toolInput=');
|
|
217
|
+
if (idx === -1) return null;
|
|
218
|
+
const rest = line.slice(idx + 10);
|
|
219
|
+
|
|
220
|
+
// Object input: toolInput={...}
|
|
221
|
+
if (rest.startsWith('{')) {
|
|
222
|
+
let depth = 0, end = 0;
|
|
223
|
+
for (let i = 0; i < rest.length; i++) {
|
|
224
|
+
if (rest[i] === '{') depth++;
|
|
225
|
+
else if (rest[i] === '}') {
|
|
226
|
+
depth--;
|
|
227
|
+
if (depth === 0) { end = i + 1; break; }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (end === 0) return null;
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(rest.slice(0, end));
|
|
233
|
+
} catch (e) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// String input: toolInput="some text" — wrap in {text:} so extractSpeechText can find it
|
|
239
|
+
if (rest.startsWith('"')) {
|
|
240
|
+
let end = 1;
|
|
241
|
+
while (end < rest.length) {
|
|
242
|
+
if (rest[end] === '"' && rest[end - 1] !== '\\') { end++; break; }
|
|
243
|
+
end++;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const str = JSON.parse(rest.slice(0, end));
|
|
247
|
+
return { text: str };
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Unquoted text: toolInput=Hello world (no braces or quotes)
|
|
254
|
+
// Take everything up to the next key=value pattern or end of line
|
|
255
|
+
const unquotedMatch = rest.match(/^([^{"\s][^\n]*?)(?:\s+\w+=[^\s]|$)/);
|
|
256
|
+
if (unquotedMatch && unquotedMatch[1].trim()) {
|
|
257
|
+
return { text: unquotedMatch[1].trim() };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Try to extract tts speech text from a raw log line using multiple patterns.
|
|
265
|
+
* Fallback for when extractToolInput returns null (openclaw format varies).
|
|
266
|
+
*/
|
|
267
|
+
extractTtsTextFromRaw(line) {
|
|
268
|
+
if (!line) return null;
|
|
269
|
+
// input="...", text="...", content="...", message="..."
|
|
270
|
+
const quoted = line.match(/(?:^|\s)(?:input|text|content|message|speech|utterance)\s*=\s*"((?:[^"\\]|\\.)*)"/i);
|
|
271
|
+
if (quoted) return quoted[1];
|
|
272
|
+
// input={text:"..."} or input={"text":"..."}
|
|
273
|
+
const jsonMatch = line.match(/(?:^|\s)(?:input|args)\s*=\s*(\{[^}]+\})/i);
|
|
274
|
+
if (jsonMatch) {
|
|
275
|
+
try {
|
|
276
|
+
const obj = JSON.parse(jsonMatch[1]);
|
|
277
|
+
if (obj.text) return obj.text;
|
|
278
|
+
if (obj.input) return obj.input;
|
|
279
|
+
} catch {}
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Build a specific, readable description from a tool name + its parsed input.
|
|
286
|
+
* Returns a rich string like "📄 Read dashboard.js (L1-200)" or null if no useful info.
|
|
287
|
+
*/
|
|
288
|
+
buildRichDescription(toolName, toolInput) {
|
|
289
|
+
if (!toolInput) return null;
|
|
290
|
+
switch (toolName) {
|
|
291
|
+
case 'read': {
|
|
292
|
+
const fp = toolInput.file_path || toolInput.path || '';
|
|
293
|
+
const base = fp ? path.basename(fp) : '';
|
|
294
|
+
if (!base) return null;
|
|
295
|
+
return `Reviewing ${base}`;
|
|
296
|
+
}
|
|
297
|
+
case 'write': {
|
|
298
|
+
const fp = toolInput.file_path || toolInput.path || '';
|
|
299
|
+
return fp ? `Writing ${path.basename(fp)}` : null;
|
|
300
|
+
}
|
|
301
|
+
case 'edit': {
|
|
302
|
+
const fp = toolInput.file_path || toolInput.path || '';
|
|
303
|
+
return fp ? `Updating ${path.basename(fp)}` : null;
|
|
304
|
+
}
|
|
305
|
+
case 'multiedit': {
|
|
306
|
+
const fp = toolInput.file_path || toolInput.path || '';
|
|
307
|
+
return fp ? `Updating ${path.basename(fp)}` : null;
|
|
308
|
+
}
|
|
309
|
+
case 'exec':
|
|
310
|
+
case 'bash': {
|
|
311
|
+
const cmd = (toolInput.command || toolInput.cmd || '').trim();
|
|
312
|
+
if (!cmd) return null;
|
|
313
|
+
// Interpret common commands naturally
|
|
314
|
+
if (/screencapture/.test(cmd)) return `Taking a screenshot`;
|
|
315
|
+
if (/^open\s/.test(cmd)) return `Opening in browser`;
|
|
316
|
+
if (/^npm\s+install/.test(cmd)) return `Installing dependencies`;
|
|
317
|
+
if (/^npm\s+run\s+(\S+)/.test(cmd)) return `Running ${cmd.match(/^npm\s+run\s+(\S+)/)[1]}`;
|
|
318
|
+
if (/^npm\s/.test(cmd)) return `Running npm`;
|
|
319
|
+
if (/^npx\s/.test(cmd)) return `Running npx`;
|
|
320
|
+
if (/^git\s+clone/.test(cmd)) return `Cloning repository`;
|
|
321
|
+
if (/^git\s+/.test(cmd)) return `Running git`;
|
|
322
|
+
if (/^curl\s/.test(cmd)) return `Making a request`;
|
|
323
|
+
if (/^python/.test(cmd)) return `Running Python script`;
|
|
324
|
+
if (/^node\s/.test(cmd)) return `Running Node.js`;
|
|
325
|
+
if (/^ls\s?/.test(cmd)) return `Listing files`;
|
|
326
|
+
if (/^mkdir/.test(cmd)) return `Creating directory`;
|
|
327
|
+
if (/^cp\s/.test(cmd)) return `Copying file`;
|
|
328
|
+
if (/^mv\s/.test(cmd)) return `Moving file`;
|
|
329
|
+
if (/^rm\s/.test(cmd)) return `Removing file`;
|
|
330
|
+
if (/^cat\s/.test(cmd)) return `Reading file`;
|
|
331
|
+
// Fallback: show trimmed command but no more than 60 chars
|
|
332
|
+
return cmd.length > 60 ? cmd.slice(0, 60) + '…' : cmd;
|
|
333
|
+
}
|
|
334
|
+
case 'web_search': {
|
|
335
|
+
const q = toolInput.query || '';
|
|
336
|
+
if (!q) return null;
|
|
337
|
+
return `Searching for "${q.length > 60 ? q.slice(0, 60) + '…' : q}"`;
|
|
338
|
+
}
|
|
339
|
+
case 'web_fetch':
|
|
340
|
+
case 'webfetch': {
|
|
341
|
+
const url = toolInput.url || '';
|
|
342
|
+
if (!url) return null;
|
|
343
|
+
try {
|
|
344
|
+
const u = new URL(url);
|
|
345
|
+
return `Reading ${u.hostname}`;
|
|
346
|
+
} catch {
|
|
347
|
+
return `Reading page`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
case 'glob': {
|
|
351
|
+
const pattern = toolInput.pattern || '';
|
|
352
|
+
return pattern ? `Finding files` : null;
|
|
353
|
+
}
|
|
354
|
+
case 'grep': {
|
|
355
|
+
const pat = toolInput.pattern || '';
|
|
356
|
+
if (!pat) return null;
|
|
357
|
+
return `Searching codebase`;
|
|
358
|
+
}
|
|
359
|
+
case 'browser': {
|
|
360
|
+
const action = (toolInput.action || '').toLowerCase();
|
|
361
|
+
if (action === 'navigate' || action === 'goto' || action === 'open') {
|
|
362
|
+
const url = toolInput.url || toolInput.targetUrl || '';
|
|
363
|
+
try { const u = new URL(url); return `Opening ${u.hostname}`; }
|
|
364
|
+
catch { return `Opening page`; }
|
|
365
|
+
}
|
|
366
|
+
if (action === 'click') return `Clicking on the page`;
|
|
367
|
+
if (action === 'screenshot' || action === 'snapshot') return `Taking a screenshot`;
|
|
368
|
+
if (action === 'type') return `Entering text`;
|
|
369
|
+
if (action === 'scroll') return `Scrolling`;
|
|
370
|
+
if (action === 'act') return `Interacting with page`;
|
|
371
|
+
return `Browsing`;
|
|
372
|
+
}
|
|
373
|
+
case 'todocreate':
|
|
374
|
+
case 'todowrite':
|
|
375
|
+
case 'todo_write':
|
|
376
|
+
return `Updating task list`;
|
|
377
|
+
case 'memory_search': {
|
|
378
|
+
const q = toolInput.query || '';
|
|
379
|
+
return q ? `Recalling "${q.slice(0, 45)}"` : `Searching memory`;
|
|
380
|
+
}
|
|
381
|
+
case 'sessions_spawn': {
|
|
382
|
+
return `Starting a sub-agent`;
|
|
383
|
+
}
|
|
384
|
+
case 'sessions_send': {
|
|
385
|
+
return `Sending message to agent`;
|
|
386
|
+
}
|
|
387
|
+
default: {
|
|
388
|
+
const firstStr = Object.values(toolInput).find(v => typeof v === 'string' && v.length > 0);
|
|
389
|
+
if (firstStr) return `${firstStr.slice(0, 60)}`;
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Parse tool activity from OpenClaw output
|
|
397
|
+
* Detects patterns like: "embedded run tool start: ... tool=read toolCallId=..."
|
|
398
|
+
* Also detects API calls, streaming, retries, and other lifecycle events
|
|
399
|
+
*/
|
|
400
|
+
parseToolActivity(line) {
|
|
401
|
+
const trimmed = line.trim();
|
|
402
|
+
|
|
403
|
+
// Map tool names to friendly descriptions (fallback when toolInput is absent)
|
|
404
|
+
const toolDescriptions = {
|
|
405
|
+
'read': 'Reviewing file',
|
|
406
|
+
'write': 'Writing file',
|
|
407
|
+
'edit': 'Updating file',
|
|
408
|
+
'exec': 'Running command',
|
|
409
|
+
'bash': 'Running command',
|
|
410
|
+
'process': 'Running process',
|
|
411
|
+
'web_search': 'Searching the web',
|
|
412
|
+
'web_fetch': 'Reading page',
|
|
413
|
+
'browser': 'Browsing',
|
|
414
|
+
'image': 'Analyzing image',
|
|
415
|
+
'memory_search': 'Searching memory',
|
|
416
|
+
'memory_get': 'Reading memory',
|
|
417
|
+
'message': 'Sending message',
|
|
418
|
+
'cron': 'Scheduling task',
|
|
419
|
+
'tts': 'Generating speech',
|
|
420
|
+
'canvas': 'Updating canvas',
|
|
421
|
+
'nodes': 'Checking nodes',
|
|
422
|
+
'gateway': 'Gateway operation',
|
|
423
|
+
'sessions_spawn': 'Starting a sub-agent',
|
|
424
|
+
'sessions_send': 'Sending message to agent',
|
|
425
|
+
'sessions_list': 'Checking active agents',
|
|
426
|
+
'sessions_history': 'Reading conversation history',
|
|
427
|
+
'session_status': 'Checking status',
|
|
428
|
+
'agents_list': 'Listing agents',
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Match OpenClaw embedded tool start pattern: "tool start: ... tool=read"
|
|
432
|
+
const toolStartMatch = trimmed.match(/tool start:.*tool=(\w+)/i);
|
|
433
|
+
if (toolStartMatch) {
|
|
434
|
+
const toolName = toolStartMatch[1].toLowerCase();
|
|
435
|
+
const toolInput = this.extractToolInput(trimmed);
|
|
436
|
+
const richDesc = toolInput ? this.buildRichDescription(toolName, toolInput) : null;
|
|
437
|
+
return {
|
|
438
|
+
event: 'tool_start',
|
|
439
|
+
tool: toolName,
|
|
440
|
+
toolInput,
|
|
441
|
+
description: richDesc || toolDescriptions[toolName] || `Running ${toolName}...`,
|
|
442
|
+
raw: trimmed
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Match tool end pattern: "tool end: ... tool=read"
|
|
447
|
+
const toolEndMatch = trimmed.match(/tool end:.*tool=(\w+)/i);
|
|
448
|
+
if (toolEndMatch) {
|
|
449
|
+
const toolName = toolEndMatch[1].toLowerCase();
|
|
450
|
+
const durationMatch = trimmed.match(/durationMs=(\d+)/);
|
|
451
|
+
const durationMs = durationMatch ? parseInt(durationMatch[1]) : null;
|
|
452
|
+
return {
|
|
453
|
+
event: 'tool_end',
|
|
454
|
+
tool: toolName,
|
|
455
|
+
durationMs,
|
|
456
|
+
description: durationMs && durationMs > 2000
|
|
457
|
+
? `✓ ${toolName} (${(durationMs / 1000).toFixed(1)}s)`
|
|
458
|
+
: `✓ ${toolName}`,
|
|
459
|
+
raw: trimmed
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Match toolResult in output (tool completed)
|
|
464
|
+
if (trimmed.includes('toolResult') || trimmed.includes('tool result')) {
|
|
465
|
+
return {
|
|
466
|
+
event: 'tool_result',
|
|
467
|
+
tool: null,
|
|
468
|
+
description: 'Processing tool result',
|
|
469
|
+
raw: trimmed
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// === API/Model lifecycle events ===
|
|
474
|
+
// OpenClaw format: "embedded run <event>: runId=... key=value ..."
|
|
475
|
+
|
|
476
|
+
// Prompt start = calling the model API (format: "embedded run prompt start:")
|
|
477
|
+
if (trimmed.includes('run prompt start:') || trimmed.includes('prompt start:')) {
|
|
478
|
+
return {
|
|
479
|
+
event: 'api_call_start',
|
|
480
|
+
tool: null,
|
|
481
|
+
description: '🌐 Calling model API...',
|
|
482
|
+
raw: trimmed
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Prompt end = model response received (format: "embedded run prompt end: ... durationMs=N")
|
|
487
|
+
const promptEndMatch = trimmed.match(/(?:run )?prompt end:.*durationMs=(\d+)/i);
|
|
488
|
+
if (promptEndMatch) {
|
|
489
|
+
const durationMs = parseInt(promptEndMatch[1]);
|
|
490
|
+
return {
|
|
491
|
+
event: 'api_call_end',
|
|
492
|
+
tool: null,
|
|
493
|
+
description: `✅ Model responded (${(durationMs/1000).toFixed(1)}s)`,
|
|
494
|
+
durationMs,
|
|
495
|
+
raw: trimmed
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Run start (format: "embedded run start: runId=... provider=... model=...")
|
|
500
|
+
if (trimmed.includes('embedded run start:') || trimmed.includes('run start:')) {
|
|
501
|
+
const modelMatch = trimmed.match(/model=([^\s]+)/);
|
|
502
|
+
const providerMatch = trimmed.match(/provider=([^\s]+)/);
|
|
503
|
+
return {
|
|
504
|
+
event: 'run_start',
|
|
505
|
+
tool: null,
|
|
506
|
+
description: `🚀 Starting run (${providerMatch?.[1] || 'unknown'}/${modelMatch?.[1] || 'unknown'})`,
|
|
507
|
+
raw: trimmed
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Run done (format: "embedded run done: ... durationMs=N aborted=...")
|
|
512
|
+
if (trimmed.includes('embedded run done:') || trimmed.includes('run done:')) {
|
|
513
|
+
const durationMatch = trimmed.match(/durationMs=(\d+)/);
|
|
514
|
+
const abortedMatch = trimmed.match(/aborted=(true|false)/);
|
|
515
|
+
const durationMs = durationMatch ? parseInt(durationMatch[1]) : null;
|
|
516
|
+
const wasAborted = abortedMatch?.[1] === 'true';
|
|
517
|
+
return {
|
|
518
|
+
event: 'run_done',
|
|
519
|
+
tool: null,
|
|
520
|
+
description: durationMs
|
|
521
|
+
? `🏁 Run ${wasAborted ? 'aborted' : 'complete'} (${(durationMs/1000).toFixed(1)}s total)`
|
|
522
|
+
: '🏁 Run complete',
|
|
523
|
+
durationMs,
|
|
524
|
+
aborted: wasAborted,
|
|
525
|
+
raw: trimmed
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Compaction (format: "embedded run compaction start:")
|
|
530
|
+
if (trimmed.includes('compaction start:')) {
|
|
531
|
+
return {
|
|
532
|
+
event: 'compaction_start',
|
|
533
|
+
tool: null,
|
|
534
|
+
description: '🗜️ Compacting context...',
|
|
535
|
+
raw: trimmed
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Compaction retry
|
|
540
|
+
if (trimmed.includes('compaction retry:')) {
|
|
541
|
+
return {
|
|
542
|
+
event: 'compaction_retry',
|
|
543
|
+
tool: null,
|
|
544
|
+
description: '🗜️ Retrying compaction...',
|
|
545
|
+
raw: trimmed
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Timeout (format: "embedded run timeout:")
|
|
550
|
+
if (trimmed.includes('run timeout:')) {
|
|
551
|
+
return {
|
|
552
|
+
event: 'timeout',
|
|
553
|
+
tool: null,
|
|
554
|
+
description: '⏰ Run timed out',
|
|
555
|
+
raw: trimmed
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Abort still streaming warning
|
|
560
|
+
if (trimmed.includes('abort still streaming:')) {
|
|
561
|
+
return {
|
|
562
|
+
event: 'abort_streaming',
|
|
563
|
+
tool: null,
|
|
564
|
+
description: '⚠️ Aborting while still streaming',
|
|
565
|
+
raw: trimmed
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Rate limits / retries (various patterns)
|
|
570
|
+
if (trimmed.includes('rate limit') || trimmed.includes('429') ||
|
|
571
|
+
trimmed.includes('retrying') || trimmed.includes('retry:')) {
|
|
572
|
+
return {
|
|
573
|
+
event: 'rate_limit',
|
|
574
|
+
tool: null,
|
|
575
|
+
description: '⏳ Rate limited, retrying...',
|
|
576
|
+
raw: trimmed
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Agent lifecycle (format: "embedded run agent start:" / "embedded run agent end:")
|
|
581
|
+
if (trimmed.includes('run agent start:') || trimmed.includes('agent start:')) {
|
|
582
|
+
return {
|
|
583
|
+
event: 'agent_start',
|
|
584
|
+
tool: null,
|
|
585
|
+
description: '🤖 Agent starting...',
|
|
586
|
+
raw: trimmed
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (trimmed.includes('run agent end:') || trimmed.includes('agent end:')) {
|
|
591
|
+
return {
|
|
592
|
+
event: 'agent_end',
|
|
593
|
+
tool: null,
|
|
594
|
+
description: '🤖 Agent finished',
|
|
595
|
+
raw: trimmed
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Strip ANSI escape codes from a string
|
|
604
|
+
*/
|
|
605
|
+
stripAnsi(str) {
|
|
606
|
+
// Matches ESC[ sequences (colors, cursor movement, etc.)
|
|
607
|
+
return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
608
|
+
.replace(/\x1b\][^\x07]*\x07/g, '') // OSC sequences
|
|
609
|
+
.replace(/\x1b[^[]/g, ''); // Other ESC sequences
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Check if a line is a diagnostic/verbose log that should be hidden from chat
|
|
614
|
+
*/
|
|
615
|
+
isDiagnosticLog(line) {
|
|
616
|
+
const trimmed = this.stripAnsi(line).trim();
|
|
617
|
+
return trimmed.includes('[agent/embedded]') ||
|
|
618
|
+
trimmed.includes('[diagnostic]') ||
|
|
619
|
+
trimmed.includes('lane enqueue:') ||
|
|
620
|
+
trimmed.includes('lane dequeue:') ||
|
|
621
|
+
trimmed.includes('lane task done:') ||
|
|
622
|
+
trimmed.includes('session state:') ||
|
|
623
|
+
trimmed.includes('run registered:') ||
|
|
624
|
+
trimmed.includes('run cleared:') ||
|
|
625
|
+
trimmed.includes('embedded run') ||
|
|
626
|
+
trimmed.includes('Registered plugin command:') ||
|
|
627
|
+
trimmed.includes('(plugin:') ||
|
|
628
|
+
// Filter out error messages - users shouldn't see technical errors
|
|
629
|
+
trimmed.startsWith('Error:') ||
|
|
630
|
+
trimmed.includes('Agent failed:') ||
|
|
631
|
+
trimmed.includes('[openclaw]') ||
|
|
632
|
+
trimmed.includes('Unhandled promise rejection') ||
|
|
633
|
+
trimmed.includes('shared_storage_worklet') ||
|
|
634
|
+
trimmed.includes('playwright-core') ||
|
|
635
|
+
trimmed.includes('ECONNREFUSED') ||
|
|
636
|
+
trimmed.includes('ETIMEDOUT') ||
|
|
637
|
+
trimmed.includes('Command exited with code') ||
|
|
638
|
+
trimmed.includes('targetInfo') ||
|
|
639
|
+
trimmed.includes('crBrowser') ||
|
|
640
|
+
trimmed.includes('crConnection') ||
|
|
641
|
+
// Image processing / tool internals
|
|
642
|
+
trimmed.startsWith('Optimized PNG') ||
|
|
643
|
+
trimmed.startsWith('Optimized JPEG') ||
|
|
644
|
+
trimmed.startsWith('Optimized image') ||
|
|
645
|
+
trimmed.includes('preserving alpha') ||
|
|
646
|
+
trimmed.includes('side≤') ||
|
|
647
|
+
trimmed.match(/^\d+x\d+px [\d.]+(KB|MB)/) ||
|
|
648
|
+
trimmed.includes('Image resized to fit') ||
|
|
649
|
+
trimmed.includes('→') && trimmed.includes('KB') && trimmed.includes('->') ||
|
|
650
|
+
trimmed.includes('(side≤');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Register an agent with Canary and get its API key
|
|
655
|
+
* Each agent gets its own identity in Canary for proper tracking
|
|
656
|
+
*/
|
|
657
|
+
async registerWithCanary(agentId, workDir) {
|
|
658
|
+
try {
|
|
659
|
+
const response = await fetch(`${CANARY_ENDPOINT}/api/agents/register`, {
|
|
660
|
+
method: 'POST',
|
|
661
|
+
headers: { 'Content-Type': 'application/json' },
|
|
662
|
+
body: JSON.stringify({
|
|
663
|
+
name: `AgentForge: ${agentId}`,
|
|
664
|
+
description: `Auto-registered agent working on ${path.basename(workDir)}`,
|
|
665
|
+
platform: 'openclaw',
|
|
666
|
+
owner_hint: 'AgentForge spawned agent',
|
|
667
|
+
// If parent API key is set, sub-agents auto-inherit ownership
|
|
668
|
+
...(CANARY_PARENT_API_KEY ? { parent_agent_api_key: CANARY_PARENT_API_KEY } : {})
|
|
669
|
+
})
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
if (!response.ok) {
|
|
673
|
+
console.log(`⚠️ Canary registration failed: ${response.status}`);
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const data = await response.json();
|
|
678
|
+
console.log(`🐤 Registered with Canary: ${data.agent?.id || 'unknown'}`);
|
|
679
|
+
|
|
680
|
+
if (data.agent?.claim_url) {
|
|
681
|
+
console.log(` Claim URL: ${data.agent.claim_url}`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return data.agent?.api_key || null;
|
|
685
|
+
} catch (error) {
|
|
686
|
+
console.log(`⚠️ Could not reach Canary: ${error.message}`);
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Create a new isolated agent
|
|
693
|
+
*/
|
|
694
|
+
async createAgent(agentId, workDir) {
|
|
695
|
+
// Ensure workspace directory exists before openclaw tries to use it
|
|
696
|
+
mkdirSync(workDir, { recursive: true });
|
|
697
|
+
|
|
698
|
+
// Register with Canary to get agent-specific API key
|
|
699
|
+
const canaryApiKey = await this.registerWithCanary(agentId, workDir);
|
|
700
|
+
|
|
701
|
+
// Store Canary API key in agent's workspace for persistence
|
|
702
|
+
if (canaryApiKey) {
|
|
703
|
+
const canaryConfigPath = path.join(workDir, '.canary');
|
|
704
|
+
writeFileSync(canaryConfigPath, JSON.stringify({
|
|
705
|
+
apiKey: canaryApiKey,
|
|
706
|
+
agentId,
|
|
707
|
+
registeredAt: new Date().toISOString()
|
|
708
|
+
}, null, 2));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return new Promise((resolve, reject) => {
|
|
712
|
+
console.log(`Creating OpenClaw agent: ${agentId}`);
|
|
713
|
+
console.log(` Working directory: ${workDir}\n`);
|
|
714
|
+
|
|
715
|
+
const proc = spawn(process.execPath, [this.bin,
|
|
716
|
+
'agents', 'add', agentId, '--workspace', workDir, '--non-interactive'
|
|
717
|
+
], { env: { ...process.env } });
|
|
718
|
+
|
|
719
|
+
let output = '';
|
|
720
|
+
let error = '';
|
|
721
|
+
|
|
722
|
+
proc.stdout.on('data', (data) => {
|
|
723
|
+
output += data.toString();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
proc.stderr.on('data', (data) => {
|
|
727
|
+
error += data.toString();
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
proc.on('error', (err) => {
|
|
731
|
+
console.error(`⚠️ Failed to spawn openclaw for createAgent: ${err.message}`);
|
|
732
|
+
resolve({ agentId, workDir }); // Don't crash - agent may already exist
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
proc.on('close', async (code) => {
|
|
736
|
+
if (code === 0) {
|
|
737
|
+
console.log(`✓ Agent created: ${agentId}`);
|
|
738
|
+
} else {
|
|
739
|
+
// Agent might already exist, which is fine
|
|
740
|
+
console.log(`⚠️ Agent creation failed or already exists: ${agentId}`);
|
|
741
|
+
if (error) {
|
|
742
|
+
console.error(` Error: ${error.trim()}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Copy AgentForge template files AFTER openclaw creates the agent
|
|
747
|
+
// This ensures MEMORY.md and memory/ dir exist for memory persistence
|
|
748
|
+
// Use bundled templates (packaged with worker) as primary source,
|
|
749
|
+
// fall back to /tmp if somehow missing
|
|
750
|
+
const bundledTemplateDir = path.join(path.dirname(new URL(import.meta.url).pathname), '../../templates/agent');
|
|
751
|
+
const templateDir = existsSync(bundledTemplateDir) ? bundledTemplateDir : '/tmp/agentforge/templates/agent';
|
|
752
|
+
console.log(`📁 Using templates from: ${templateDir}`);
|
|
753
|
+
try {
|
|
754
|
+
if (existsSync(templateDir)) {
|
|
755
|
+
const { execSync } = await import('child_process');
|
|
756
|
+
|
|
757
|
+
// Copy MEMORY.md if it doesn't exist
|
|
758
|
+
const memoryMdSrc = path.join(templateDir, 'MEMORY.md');
|
|
759
|
+
const memoryMdDst = path.join(workDir, 'MEMORY.md');
|
|
760
|
+
if (existsSync(memoryMdSrc) && !existsSync(memoryMdDst)) {
|
|
761
|
+
execSync(`cp "${memoryMdSrc}" "${memoryMdDst}"`, { stdio: 'ignore' });
|
|
762
|
+
console.log(`📁 Added MEMORY.md template`);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Create memory/ directory if it doesn't exist
|
|
766
|
+
const memoryDir = path.join(workDir, 'memory');
|
|
767
|
+
if (!existsSync(memoryDir)) {
|
|
768
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
769
|
+
console.log(`📁 Created memory/ directory`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Copy core identity/personality files — always overwrite so all machines stay in sync
|
|
773
|
+
for (const fname of ['AGENTS.md', 'SOUL.md', 'TOOLS.md']) {
|
|
774
|
+
const src = path.join(templateDir, fname);
|
|
775
|
+
const dst = path.join(workDir, fname);
|
|
776
|
+
if (existsSync(src)) {
|
|
777
|
+
execSync(`cp "${src}" "${dst}"`, { stdio: 'ignore' });
|
|
778
|
+
console.log(`📁 Applied ${fname} template`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Copy AGENTFORGE.md (platform guide with projects path)
|
|
783
|
+
const agentforgeMdSrc = path.join(templateDir, 'AGENTFORGE.md');
|
|
784
|
+
const agentforgeMdDst = path.join(workDir, 'AGENTFORGE.md');
|
|
785
|
+
if (existsSync(agentforgeMdSrc)) {
|
|
786
|
+
execSync(`cp "${agentforgeMdSrc}" "${agentforgeMdDst}"`, { stdio: 'ignore' });
|
|
787
|
+
console.log(`📁 Added AGENTFORGE.md platform guide`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
} catch (err) {
|
|
791
|
+
console.warn(`⚠️ Template setup failed: ${err.message}`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
resolve({ agentId, workDir });
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Run an agent task
|
|
801
|
+
* Images are saved to workspace and referenced in message for vision model analysis
|
|
802
|
+
*/
|
|
803
|
+
async runAgentTask(agentId, task, workDir, sessionId = null, image = null, browserProfile = null, imageWorkDir = null) {
|
|
804
|
+
// ── Gateway path disabled — subprocess shows live tool activity ──────────
|
|
805
|
+
// Gateway path: SSE token streaming — tokens arrive live as the model generates.
|
|
806
|
+
// Dashboard buffers tokens into sentences before showing each as a complete bubble.
|
|
807
|
+
if (!image && this.gatewayPort && this.gatewayToken) {
|
|
808
|
+
console.log(`\n🤖 Running agent (streaming): ${agentId}`);
|
|
809
|
+
console.log(` Task: ${task.slice(0, 120)}${task.length > 120 ? '…' : ''}`);
|
|
810
|
+
try {
|
|
811
|
+
const streamResult = await this._runAgentTaskStreaming(agentId, task, sessionId);
|
|
812
|
+
if (streamResult !== null) {
|
|
813
|
+
// Gateway request succeeded (streamResult.succeeded === true).
|
|
814
|
+
// Use the text response if any; if the agent only did tool work with no
|
|
815
|
+
// text output, return empty string — do NOT fall back to subprocess (which
|
|
816
|
+
// would re-run the same task a second time and corrupt the session state).
|
|
817
|
+
const responseText = streamResult.text || '';
|
|
818
|
+
if (!responseText) {
|
|
819
|
+
console.log(`[${agentId}] ✅ Gateway task completed with no text output (tool-only task)`);
|
|
820
|
+
}
|
|
821
|
+
let identity = { identityName: agentId, identityEmoji: '🤖' };
|
|
822
|
+
try { identity = await this.getAgentIdentity(agentId); } catch { /* ignore */ }
|
|
823
|
+
this.emit('agent_completed', { agentId, duration: 0, result: { output: responseText }, identity });
|
|
824
|
+
return { success: true, agentId, duration: 0, result: { output: responseText }, identity };
|
|
825
|
+
}
|
|
826
|
+
console.warn(`[${agentId}] ⚠️ Streaming request failed — falling back to subprocess`);
|
|
827
|
+
} catch (err) {
|
|
828
|
+
console.warn(`[${agentId}] ⚠️ Streaming failed (${err.message}) — falling back to subprocess`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// ── Subprocess fallback ────────────────────────────────────────────────────
|
|
832
|
+
return new Promise(async (resolve, reject) => {
|
|
833
|
+
console.log(`\n🤖 Running agent: ${agentId}`);
|
|
834
|
+
console.log(` Task: ${task}`);
|
|
835
|
+
console.log(` Working dir: ${workDir}`);
|
|
836
|
+
if (sessionId) {
|
|
837
|
+
console.log(` Session: ${sessionId}`);
|
|
838
|
+
}
|
|
839
|
+
if (browserProfile) {
|
|
840
|
+
console.log(` Browser profile: ${browserProfile}`);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
let imagePath = null;
|
|
844
|
+
let modifiedTask = task;
|
|
845
|
+
|
|
846
|
+
// Handle image by saving to workspace
|
|
847
|
+
if (image) {
|
|
848
|
+
try {
|
|
849
|
+
const fs = await import('fs/promises');
|
|
850
|
+
const path = await import('path');
|
|
851
|
+
|
|
852
|
+
// Use imageWorkDir (agent's actual workspace) if provided, otherwise fall back to workDir
|
|
853
|
+
const imageDir = imageWorkDir || workDir;
|
|
854
|
+
|
|
855
|
+
// Ensure workspace directory exists before saving image
|
|
856
|
+
await fs.mkdir(imageDir, { recursive: true });
|
|
857
|
+
|
|
858
|
+
// Extract base64 data (remove data:image/png;base64, prefix if present)
|
|
859
|
+
const base64Data = image.replace(/^data:image\/\w+;base64,/, '');
|
|
860
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
861
|
+
|
|
862
|
+
// Save to workspace with timestamp to avoid conflicts
|
|
863
|
+
const timestamp = Date.now();
|
|
864
|
+
const imageFileName = `uploaded_image_${timestamp}.png`;
|
|
865
|
+
imagePath = path.join(imageDir, imageFileName);
|
|
866
|
+
|
|
867
|
+
await fs.writeFile(imagePath, buffer);
|
|
868
|
+
console.log(` 📷 Image saved to: ${imagePath}`);
|
|
869
|
+
|
|
870
|
+
// Modify task to reference the image
|
|
871
|
+
modifiedTask = `${task}\n\n[There is an image file in the workspace: ${imageFileName}]`;
|
|
872
|
+
} catch (error) {
|
|
873
|
+
console.error(` ⚠️ Failed to save image: ${error.message}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
console.log('\n');
|
|
878
|
+
|
|
879
|
+
const startTime = Date.now();
|
|
880
|
+
let runCompleted = false;
|
|
881
|
+
let completionTimer = null;
|
|
882
|
+
let promiseSettled = false;
|
|
883
|
+
let agentEndSeen = false; // true once "agent end: isError=false" fires
|
|
884
|
+
|
|
885
|
+
// Build command arguments with potentially modified task
|
|
886
|
+
// --local is required since openclaw 2026.3.8 to run embedded instead of via Gateway
|
|
887
|
+
const args = [
|
|
888
|
+
'agent',
|
|
889
|
+
'--local',
|
|
890
|
+
'--agent', agentId,
|
|
891
|
+
'--message', modifiedTask,
|
|
892
|
+
'--verbose', 'on' // Enable tool call output
|
|
893
|
+
];
|
|
894
|
+
|
|
895
|
+
// Add session ID for conversation persistence
|
|
896
|
+
if (sessionId) {
|
|
897
|
+
args.push('--session-id', sessionId);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Browser profile selection (cdp-url flag removed - not supported by openclaw agent)
|
|
901
|
+
|
|
902
|
+
// Ensure working directory exists before spawning
|
|
903
|
+
mkdirSync(workDir, { recursive: true });
|
|
904
|
+
|
|
905
|
+
// Load agent-specific Canary API key if available
|
|
906
|
+
let canaryApiKey = null;
|
|
907
|
+
try {
|
|
908
|
+
const canaryConfigPath = path.join(workDir, '.canary');
|
|
909
|
+
if (existsSync(canaryConfigPath)) {
|
|
910
|
+
const canaryConfig = JSON.parse(readFileSync(canaryConfigPath, 'utf-8'));
|
|
911
|
+
canaryApiKey = canaryConfig.apiKey;
|
|
912
|
+
}
|
|
913
|
+
} catch {
|
|
914
|
+
// No Canary config, that's fine
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Build environment with agent-specific Canary key + valid Anthropic API key
|
|
918
|
+
const agentEnv = { ...process.env };
|
|
919
|
+
if (canaryApiKey) {
|
|
920
|
+
agentEnv.CANARY_API_KEY = canaryApiKey;
|
|
921
|
+
console.log(` 🐤 Canary tracking: enabled (agent-specific key)`);
|
|
922
|
+
}
|
|
923
|
+
// Inject valid Anthropic key directly — bypasses broken auth-profiles.json on any machine.
|
|
924
|
+
// Prefer the locally cached key, but fall back to process.env which may be set later by the server.
|
|
925
|
+
const resolvedAnthropicKey = this.anthropicApiKey || process.env.ANTHROPIC_API_KEY || null;
|
|
926
|
+
if (resolvedAnthropicKey) {
|
|
927
|
+
agentEnv.ANTHROPIC_API_KEY = resolvedAnthropicKey;
|
|
928
|
+
// Keep the instance cache updated so subsequent spawns reuse the injected key without re-reading env
|
|
929
|
+
if (!this.anthropicApiKey) {
|
|
930
|
+
this.anthropicApiKey = resolvedAnthropicKey;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Auto-fix auth-profiles.json before spawning: remove invalid anthropic:default key
|
|
935
|
+
// This self-heals any machine where the default key returns 401
|
|
936
|
+
try {
|
|
937
|
+
const authPath = path.join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json');
|
|
938
|
+
if (existsSync(authPath)) {
|
|
939
|
+
const authData = JSON.parse(readFileSync(authPath, 'utf-8'));
|
|
940
|
+
if (authData?.profiles?.['anthropic:default']) {
|
|
941
|
+
delete authData.profiles['anthropic:default'];
|
|
942
|
+
if (!authData.lastGood) authData.lastGood = {};
|
|
943
|
+
authData.lastGood['anthropic'] = 'anthropic:manual';
|
|
944
|
+
writeFileSync(authPath, JSON.stringify(authData, null, 2));
|
|
945
|
+
console.log(`[${agentId}] 🔑 Auto-fixed auth: removed invalid anthropic:default key`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
} catch (authFixErr) {
|
|
949
|
+
// Non-fatal - openclaw will fail with 401 if key is bad, which is catchable
|
|
950
|
+
console.warn(`[${agentId}] ⚠️ Auth fix skipped: ${authFixErr.message}`);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Kill any existing process for this agent before spawning — prevents race condition
|
|
954
|
+
// where task 1 resolves via runCompleted but its process is still alive for 5s,
|
|
955
|
+
// task 2 spawns a new process, both fight over session files, task 2 hangs.
|
|
956
|
+
const existingAgent = this.activeAgents.get(agentId);
|
|
957
|
+
if (existingAgent && existingAgent.proc && !existingAgent.proc.killed) {
|
|
958
|
+
console.log(`[${agentId}] 🔪 Killing lingering process (pid ${existingAgent.proc.pid}) before spawning new one`);
|
|
959
|
+
try { treeKill(existingAgent.proc.pid, 'SIGKILL'); } catch (e) { /* already dead */ }
|
|
960
|
+
this.activeAgents.delete(agentId);
|
|
961
|
+
// Wait for process to fully exit and release file locks before spawning
|
|
962
|
+
await new Promise(r => setTimeout(r, 800));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Change to working directory and run agent
|
|
966
|
+
// Use process.execPath (node) directly to avoid shell metacharacter issues
|
|
967
|
+
// with user message content (quotes, apostrophes, etc.)
|
|
968
|
+
const proc = spawn(process.execPath, [this.bin, ...args], {
|
|
969
|
+
cwd: workDir,
|
|
970
|
+
env: agentEnv
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
let output = '';
|
|
974
|
+
let filteredOutput = ''; // user-facing output only — no diagnostic/system logs
|
|
975
|
+
let error = '';
|
|
976
|
+
let recentLines = []; // rolling buffer for TTS context debugging
|
|
977
|
+
let firstOutputSeen = false;
|
|
978
|
+
|
|
979
|
+
// Kill if openclaw produces zero output for 90s — means it's hung on API call
|
|
980
|
+
const firstOutputTimer = setTimeout(() => {
|
|
981
|
+
if (!firstOutputSeen && !promiseSettled) {
|
|
982
|
+
console.warn(`[${agentId}] ⚠️ No output in 90s — openclaw hung, killing`);
|
|
983
|
+
try { proc.kill('SIGKILL'); } catch (e) { /* already dead */ }
|
|
984
|
+
if (!promiseSettled) {
|
|
985
|
+
promiseSettled = true;
|
|
986
|
+
this.activeAgents.delete(agentId);
|
|
987
|
+
reject(new Error('openclaw produced no output within 90s — possible API hang'));
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}, 90000);
|
|
991
|
+
|
|
992
|
+
proc.stdout.on('data', (data) => {
|
|
993
|
+
firstOutputSeen = true;
|
|
994
|
+
clearTimeout(firstOutputTimer);
|
|
995
|
+
const text = data.toString();
|
|
996
|
+
output += text;
|
|
997
|
+
|
|
998
|
+
// Process line by line to filter technical logs
|
|
999
|
+
const lines = text.split('\n');
|
|
1000
|
+
const filteredLines = [];
|
|
1001
|
+
|
|
1002
|
+
for (const line of lines) {
|
|
1003
|
+
const trimmed = this.stripAnsi(line).trim();
|
|
1004
|
+
|
|
1005
|
+
// Detect "agent end: isError=false" — the agent's actual work is done.
|
|
1006
|
+
// After this, openclaw may do compaction (LLM call) and then hang without
|
|
1007
|
+
// ever emitting "embedded run done". Start a 30s grace timer so if the
|
|
1008
|
+
// process doesn't exit cleanly we force-resolve and unblock the queue.
|
|
1009
|
+
if (!agentEndSeen && trimmed.includes('run agent end:') && trimmed.includes('isError=false')) {
|
|
1010
|
+
agentEndSeen = true;
|
|
1011
|
+
console.log(`[${agentId}] ✅ Agent task finished (agent end isError=false), starting 30s grace timer`);
|
|
1012
|
+
if (!completionTimer) {
|
|
1013
|
+
completionTimer = setTimeout(async () => {
|
|
1014
|
+
if (runCompleted || promiseSettled) return; // already handled
|
|
1015
|
+
console.log(`[${agentId}] ⚠️ Process still running 30s after agent end — force killing (compaction hung?)`);
|
|
1016
|
+
runCompleted = true;
|
|
1017
|
+
try { proc.kill('SIGTERM'); } catch (e) { /* already dead */ }
|
|
1018
|
+
setTimeout(() => { try { proc.kill('SIGKILL'); } catch (e) {} }, 1000);
|
|
1019
|
+
if (!promiseSettled) {
|
|
1020
|
+
promiseSettled = true;
|
|
1021
|
+
const duration = Date.now() - startTime;
|
|
1022
|
+
let identity = { identityName: agentId, identityEmoji: '🤖' };
|
|
1023
|
+
try {
|
|
1024
|
+
identity = await Promise.race([
|
|
1025
|
+
this.getAgentIdentity(agentId),
|
|
1026
|
+
new Promise(r => setTimeout(() => r(identity), 5000))
|
|
1027
|
+
]);
|
|
1028
|
+
} catch (e) { /* use default */ }
|
|
1029
|
+
this.activeAgents.delete(agentId);
|
|
1030
|
+
let result;
|
|
1031
|
+
try { result = JSON.parse(output); } catch (e) { result = { output: filteredOutput }; }
|
|
1032
|
+
console.log(`\n✅ Agent ${agentId} (${identity.identityName}) completed in ${(duration / 1000).toFixed(2)}s (force-resolved after compaction hang)\n`);
|
|
1033
|
+
this.emit('agent_completed', { agentId, duration, result, identity });
|
|
1034
|
+
resolve({ success: true, agentId, duration, result, identity });
|
|
1035
|
+
}
|
|
1036
|
+
}, 30000);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Detect when OpenClaw run completes - process should exit soon after
|
|
1041
|
+
// Pattern: "embedded run done" or "run_completed"
|
|
1042
|
+
if (trimmed.includes('embedded run done') || trimmed.includes('run_completed')) {
|
|
1043
|
+
if (!runCompleted) {
|
|
1044
|
+
runCompleted = true;
|
|
1045
|
+
// Cancel the agentEnd grace timer — clean exit happened
|
|
1046
|
+
if (completionTimer) { clearTimeout(completionTimer); completionTimer = null; }
|
|
1047
|
+
console.log(`[${agentId}] 🏁 Run completed, waiting for process exit...`);
|
|
1048
|
+
|
|
1049
|
+
// Give process 5 seconds to exit gracefully, then force kill and resolve
|
|
1050
|
+
completionTimer = setTimeout(async () => {
|
|
1051
|
+
if (proc && !proc.killed) {
|
|
1052
|
+
console.log(`[${agentId}] ⚠️ Process didn't exit after run completed, force killing`);
|
|
1053
|
+
try {
|
|
1054
|
+
proc.kill('SIGTERM');
|
|
1055
|
+
setTimeout(() => {
|
|
1056
|
+
if (!proc.killed) {
|
|
1057
|
+
proc.kill('SIGKILL');
|
|
1058
|
+
}
|
|
1059
|
+
}, 1000);
|
|
1060
|
+
} catch (e) {
|
|
1061
|
+
// Process might already be dead
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
// Task completed successfully — resolve now instead of waiting for close event.
|
|
1065
|
+
// The close event can hang indefinitely if openclaw's child subprocesses keep
|
|
1066
|
+
// stdio pipes open after the parent is killed.
|
|
1067
|
+
if (!promiseSettled) {
|
|
1068
|
+
promiseSettled = true;
|
|
1069
|
+
const duration = Date.now() - startTime;
|
|
1070
|
+
let identity = { identityName: agentId, identityEmoji: '🤖' };
|
|
1071
|
+
try {
|
|
1072
|
+
identity = await Promise.race([
|
|
1073
|
+
this.getAgentIdentity(agentId),
|
|
1074
|
+
new Promise(r => setTimeout(() => r(identity), 5000))
|
|
1075
|
+
]);
|
|
1076
|
+
} catch (e) { /* use default */ }
|
|
1077
|
+
this.activeAgents.delete(agentId);
|
|
1078
|
+
let result;
|
|
1079
|
+
try { result = JSON.parse(output); } catch (e) { result = { output: filteredOutput }; }
|
|
1080
|
+
console.log(`\n✅ Agent ${agentId} (${identity.identityName}) completed in ${(duration / 1000).toFixed(2)}s (force-resolved after kill)\n`);
|
|
1081
|
+
this.emit('agent_completed', { agentId, duration, result, identity });
|
|
1082
|
+
resolve({ success: true, agentId, duration, result, identity });
|
|
1083
|
+
}
|
|
1084
|
+
}, 5000);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Rolling buffer of last 15 raw lines for TTS context debugging
|
|
1089
|
+
if (!recentLines) recentLines = [];
|
|
1090
|
+
recentLines.push(line);
|
|
1091
|
+
if (recentLines.length > 15) recentLines.shift();
|
|
1092
|
+
|
|
1093
|
+
// Only filter out EXACT system message patterns (starts with these)
|
|
1094
|
+
const isSystemLog = trimmed.startsWith('[Canary]') ||
|
|
1095
|
+
trimmed.startsWith('[agents/') ||
|
|
1096
|
+
trimmed.includes('Plugin registered') ||
|
|
1097
|
+
trimmed.includes('Registered plugin command:') ||
|
|
1098
|
+
trimmed.includes('inherited auth-profiles') ||
|
|
1099
|
+
trimmed.includes('gateway tool:') ||
|
|
1100
|
+
trimmed.includes('Debugger listening') ||
|
|
1101
|
+
trimmed.includes('[diagnostic]') ||
|
|
1102
|
+
trimmed.includes('[agent/embedded]') ||
|
|
1103
|
+
trimmed.includes('browser/service') ||
|
|
1104
|
+
trimmed.includes('Browser control service') ||
|
|
1105
|
+
trimmed.includes('profiles=');
|
|
1106
|
+
|
|
1107
|
+
// Detect AGENTFORGE_IMAGE:/path — agent wants to send a screenshot to the user's chat
|
|
1108
|
+
if (trimmed.startsWith('AGENTFORGE_IMAGE:')) {
|
|
1109
|
+
const imagePath = trimmed.slice('AGENTFORGE_IMAGE:'.length).trim();
|
|
1110
|
+
try {
|
|
1111
|
+
const imageData = readFileSync(imagePath);
|
|
1112
|
+
const ext = imagePath.split('.').pop().toLowerCase();
|
|
1113
|
+
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'gif' ? 'image/gif' : 'image/png';
|
|
1114
|
+
const base64 = `data:${mime};base64,${imageData.toString('base64')}`;
|
|
1115
|
+
this.emit('agent_image', { agentId, image: base64 });
|
|
1116
|
+
console.log(`[${agentId}] 📸 Sending screenshot to chat (${Math.round(imageData.length / 1024)}KB)`);
|
|
1117
|
+
} catch (e) {
|
|
1118
|
+
console.warn(`[${agentId}] ⚠️ Could not read image ${imagePath}:`, e.message);
|
|
1119
|
+
}
|
|
1120
|
+
continue; // Don't show the marker line in chat
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Always emit agent_alive + parse tool activity for ANY stdout line —
|
|
1124
|
+
// [agent/embedded] lines are system logs but still carry tool start/end events
|
|
1125
|
+
if (trimmed.length > 0) {
|
|
1126
|
+
this.emit('agent_alive', { agentId });
|
|
1127
|
+
const toolActivityAny = this.parseToolActivity(line);
|
|
1128
|
+
if (toolActivityAny) {
|
|
1129
|
+
if (toolActivityAny.tool === 'tts' && toolActivityAny.event === 'tool_start' && !toolActivityAny.toolInput) {
|
|
1130
|
+
this.emit('tts_context', { agentId, lines: [...recentLines] });
|
|
1131
|
+
}
|
|
1132
|
+
this.emit('tool_activity', { agentId, ...toolActivityAny });
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (!isSystemLog && trimmed.length > 0) {
|
|
1137
|
+
// Drop OpenClaw placeholder responses that confuse the UI
|
|
1138
|
+
if (/^no reply from agent\.?$/i.test(trimmed)) {
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
// tool activity already emitted above for all lines
|
|
1142
|
+
const toolActivity = this.parseToolActivity(line);
|
|
1143
|
+
|
|
1144
|
+
// Filter out diagnostic/verbose logs from chat
|
|
1145
|
+
if (!this.isDiagnosticLog(line) && !toolActivity) {
|
|
1146
|
+
// Always push ANSI-stripped version so raw escape codes never reach the UI
|
|
1147
|
+
filteredLines.push(trimmed);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Detect tool calls and emit current action
|
|
1152
|
+
const currentAction = this.parseCurrentAction(trimmed);
|
|
1153
|
+
if (currentAction) {
|
|
1154
|
+
this.emit('current_action', {
|
|
1155
|
+
agentId,
|
|
1156
|
+
action: currentAction
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (trimmed.length > 0) {
|
|
1161
|
+
console.log(`[${agentId}] ${trimmed}`);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Emit filtered output and accumulate for task_complete response
|
|
1166
|
+
if (filteredLines.length > 0) {
|
|
1167
|
+
const filteredChunk = filteredLines.join('\n') + '\n';
|
|
1168
|
+
filteredOutput += (filteredOutput ? '\n' : '') + filteredChunk;
|
|
1169
|
+
this.emit('agent_output', {
|
|
1170
|
+
agentId,
|
|
1171
|
+
output: filteredChunk
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
proc.on('error', (err) => {
|
|
1177
|
+
clearTimeout(firstOutputTimer);
|
|
1178
|
+
console.error(`❌ Failed to spawn openclaw for runAgentTask: ${err.message}`);
|
|
1179
|
+
this.activeAgents.delete(agentId);
|
|
1180
|
+
if (!promiseSettled) {
|
|
1181
|
+
promiseSettled = true;
|
|
1182
|
+
reject(new Error(`Failed to spawn openclaw: ${err.message}`));
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
proc.stderr.on('data', (data) => {
|
|
1187
|
+
const text = data.toString();
|
|
1188
|
+
error += text;
|
|
1189
|
+
|
|
1190
|
+
this.emit('agent_error', {
|
|
1191
|
+
agentId,
|
|
1192
|
+
error: text
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// Print stderr in real-time
|
|
1196
|
+
console.error(`[${agentId}] [stderr] ${text.trim()}`);
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
proc.on('close', async (code) => {
|
|
1200
|
+
const duration = Date.now() - startTime;
|
|
1201
|
+
clearTimeout(firstOutputTimer);
|
|
1202
|
+
|
|
1203
|
+
// Clear the completion timer if it's still running
|
|
1204
|
+
if (completionTimer) {
|
|
1205
|
+
clearTimeout(completionTimer);
|
|
1206
|
+
completionTimer = null;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// If promise already settled by force-resolve after kill, skip
|
|
1210
|
+
// IMPORTANT: only delete from activeAgents if this is still OUR process —
|
|
1211
|
+
// a newer task may have already replaced the entry with a new process
|
|
1212
|
+
if (promiseSettled) {
|
|
1213
|
+
const tracked = this.activeAgents.get(agentId);
|
|
1214
|
+
if (tracked && tracked.proc === proc) {
|
|
1215
|
+
this.activeAgents.delete(agentId);
|
|
1216
|
+
}
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
promiseSettled = true;
|
|
1220
|
+
|
|
1221
|
+
// Cleanup image file if it was created
|
|
1222
|
+
if (imagePath) {
|
|
1223
|
+
try {
|
|
1224
|
+
const fs = await import('fs/promises');
|
|
1225
|
+
await fs.unlink(imagePath);
|
|
1226
|
+
console.log(` 🗑️ Cleaned up image file`);
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
// Ignore cleanup errors
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Get agent identity info
|
|
1233
|
+
const identity = await this.getAgentIdentity(agentId);
|
|
1234
|
+
|
|
1235
|
+
// Clean up activeAgents so stale processes don't confuse future tasks
|
|
1236
|
+
this.activeAgents.delete(agentId);
|
|
1237
|
+
|
|
1238
|
+
if (code === 0) {
|
|
1239
|
+
console.log(`\n✅ Agent ${agentId} (${identity.identityName}) completed in ${(duration / 1000).toFixed(2)}s\n`);
|
|
1240
|
+
|
|
1241
|
+
let result;
|
|
1242
|
+
try {
|
|
1243
|
+
// Try to parse JSON output
|
|
1244
|
+
result = JSON.parse(output);
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
// Use filteredOutput (user-facing only) — raw `output` contains diagnostic logs
|
|
1247
|
+
result = { output: filteredOutput || output };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
this.emit('agent_completed', {
|
|
1251
|
+
agentId,
|
|
1252
|
+
duration,
|
|
1253
|
+
result,
|
|
1254
|
+
identity
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
resolve({
|
|
1258
|
+
success: true,
|
|
1259
|
+
agentId,
|
|
1260
|
+
duration,
|
|
1261
|
+
result,
|
|
1262
|
+
identity
|
|
1263
|
+
});
|
|
1264
|
+
} else {
|
|
1265
|
+
console.error(`\n❌ Agent ${agentId} failed (exit code ${code})`);
|
|
1266
|
+
if (error) {
|
|
1267
|
+
console.error(`Error output:\n${error}`);
|
|
1268
|
+
}
|
|
1269
|
+
console.error('');
|
|
1270
|
+
|
|
1271
|
+
this.emit('agent_failed', {
|
|
1272
|
+
agentId,
|
|
1273
|
+
error,
|
|
1274
|
+
code
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
reject(new Error(`Agent failed: ${error || 'Unknown error'}`));
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
// Track active agent
|
|
1282
|
+
this.activeAgents.set(agentId, {
|
|
1283
|
+
proc,
|
|
1284
|
+
startTime,
|
|
1285
|
+
task,
|
|
1286
|
+
workDir
|
|
1287
|
+
});
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Spawn and run multiple agents in parallel
|
|
1293
|
+
*/
|
|
1294
|
+
async runMultipleAgents(agentConfigs) {
|
|
1295
|
+
console.log(`\n${'='.repeat(80)}`);
|
|
1296
|
+
console.log(`🚀 Spawning ${agentConfigs.length} OpenClaw agents in parallel`);
|
|
1297
|
+
console.log(`${'='.repeat(80)}\n`);
|
|
1298
|
+
|
|
1299
|
+
// First, ensure all agents exist
|
|
1300
|
+
const createPromises = agentConfigs.map(config =>
|
|
1301
|
+
this.createAgent(config.agentId, config.workDir)
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
await Promise.allSettled(createPromises);
|
|
1305
|
+
|
|
1306
|
+
// Then run all agent tasks in parallel
|
|
1307
|
+
const runPromises = agentConfigs.map(config =>
|
|
1308
|
+
this.runAgentTask(config.agentId, config.task, config.workDir)
|
|
1309
|
+
);
|
|
1310
|
+
|
|
1311
|
+
const results = await Promise.allSettled(runPromises);
|
|
1312
|
+
|
|
1313
|
+
const successful = results.filter(r => r.status === 'fulfilled').length;
|
|
1314
|
+
const failed = results.filter(r => r.status === 'rejected').length;
|
|
1315
|
+
|
|
1316
|
+
console.log(`\n${'='.repeat(80)}`);
|
|
1317
|
+
console.log(`📊 Results: ${successful} succeeded, ${failed} failed`);
|
|
1318
|
+
console.log(`${'='.repeat(80)}\n`);
|
|
1319
|
+
|
|
1320
|
+
return results;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* List all agents (with timeout to prevent hanging)
|
|
1325
|
+
*/
|
|
1326
|
+
async listAgents() {
|
|
1327
|
+
return new Promise((resolve, reject) => {
|
|
1328
|
+
const proc = spawn(process.execPath, [this.bin, 'agents', 'list', '--json']);
|
|
1329
|
+
let output = '';
|
|
1330
|
+
let resolved = false;
|
|
1331
|
+
|
|
1332
|
+
// 10 second timeout - if this hangs, don't block forever
|
|
1333
|
+
const timeout = setTimeout(() => {
|
|
1334
|
+
if (!resolved) {
|
|
1335
|
+
resolved = true;
|
|
1336
|
+
console.log('⚠️ listAgents timed out after 10s');
|
|
1337
|
+
try { proc.kill('SIGKILL'); } catch (e) {}
|
|
1338
|
+
resolve([]);
|
|
1339
|
+
}
|
|
1340
|
+
}, 10000);
|
|
1341
|
+
|
|
1342
|
+
proc.stdout.on('data', (data) => {
|
|
1343
|
+
output += data.toString();
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
proc.on('error', (err) => {
|
|
1347
|
+
if (!resolved) {
|
|
1348
|
+
resolved = true;
|
|
1349
|
+
clearTimeout(timeout);
|
|
1350
|
+
console.error('⚠️ listAgents error:', err.message);
|
|
1351
|
+
resolve([]);
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
proc.on('close', (code) => {
|
|
1356
|
+
if (!resolved) {
|
|
1357
|
+
resolved = true;
|
|
1358
|
+
clearTimeout(timeout);
|
|
1359
|
+
if (code === 0) {
|
|
1360
|
+
try {
|
|
1361
|
+
const agents = JSON.parse(output);
|
|
1362
|
+
resolve(agents);
|
|
1363
|
+
} catch (e) {
|
|
1364
|
+
resolve([]);
|
|
1365
|
+
}
|
|
1366
|
+
} else {
|
|
1367
|
+
resolve([]);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Get agent identity info (name, emoji) from OpenClaw
|
|
1376
|
+
* Has a 15s total timeout to prevent blocking
|
|
1377
|
+
*/
|
|
1378
|
+
async getAgentIdentity(agentId) {
|
|
1379
|
+
try {
|
|
1380
|
+
// Race against a timeout
|
|
1381
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
1382
|
+
setTimeout(() => resolve(null), 15000);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
const agents = await Promise.race([this.listAgents(), timeoutPromise]);
|
|
1386
|
+
|
|
1387
|
+
if (agents) {
|
|
1388
|
+
const agent = agents.find(a => a.id === agentId);
|
|
1389
|
+
if (agent) {
|
|
1390
|
+
return {
|
|
1391
|
+
identityName: agent.identityName || agent.name || agentId,
|
|
1392
|
+
identityEmoji: agent.identityEmoji || '🤖'
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
} catch (e) {
|
|
1397
|
+
console.error('Failed to get agent identity:', e);
|
|
1398
|
+
}
|
|
1399
|
+
return { identityName: agentId, identityEmoji: '🤖' };
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Get active agents
|
|
1404
|
+
*/
|
|
1405
|
+
getActiveAgents() {
|
|
1406
|
+
return Array.from(this.activeAgents.values());
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Parse output line to detect current action/tool being used
|
|
1411
|
+
* Returns a human-readable action string or null
|
|
1412
|
+
*/
|
|
1413
|
+
parseCurrentAction(line) {
|
|
1414
|
+
if (!line) return null;
|
|
1415
|
+
|
|
1416
|
+
// Tool call patterns - OpenClaw outputs these when calling tools
|
|
1417
|
+
const toolPatterns = [
|
|
1418
|
+
// Direct tool invocations
|
|
1419
|
+
{ pattern: /\[tools?\]\s*(\w+)/i, format: (m) => this.formatToolName(m[1]) },
|
|
1420
|
+
{ pattern: /calling\s+(\w+)/i, format: (m) => this.formatToolName(m[1]) },
|
|
1421
|
+
{ pattern: /tool:\s*(\w+)/i, format: (m) => this.formatToolName(m[1]) },
|
|
1422
|
+
|
|
1423
|
+
// Browser actions
|
|
1424
|
+
{ pattern: /browser.*?(snapshot|click|navigate|open|type|screenshot)/i, format: () => '🌐 Using browser' },
|
|
1425
|
+
{ pattern: /opening.*?url/i, format: () => '🌐 Opening URL' },
|
|
1426
|
+
{ pattern: /taking.*?screenshot/i, format: () => '📸 Taking screenshot' },
|
|
1427
|
+
|
|
1428
|
+
// File operations
|
|
1429
|
+
{ pattern: /\bread\b.*?file|reading\s+\w+\.(js|ts|py|md|json|txt|html|css)/i, format: () => '📄 Reading file' },
|
|
1430
|
+
{ pattern: /\bwrite\b.*?file|writing\s+to/i, format: () => '✏️ Writing file' },
|
|
1431
|
+
{ pattern: /\bedit\b.*?file|editing/i, format: () => '✏️ Editing file' },
|
|
1432
|
+
|
|
1433
|
+
// Command execution
|
|
1434
|
+
{ pattern: /\bexec\b|executing|running\s+command|spawn/i, format: () => '⚡ Running command' },
|
|
1435
|
+
{ pattern: /\$\s*\w+|bash|shell/i, format: () => '⚡ Running command' },
|
|
1436
|
+
|
|
1437
|
+
// Search/web
|
|
1438
|
+
{ pattern: /web_search|searching.*?web/i, format: () => '🔍 Searching web' },
|
|
1439
|
+
{ pattern: /web_fetch|fetching.*?url/i, format: () => '🌐 Fetching URL' },
|
|
1440
|
+
|
|
1441
|
+
// Memory
|
|
1442
|
+
{ pattern: /memory_search/i, format: () => '🧠 Searching memory' },
|
|
1443
|
+
|
|
1444
|
+
// Messages
|
|
1445
|
+
{ pattern: /\bmessage\b.*?send|sending.*?message/i, format: () => '💬 Sending message' },
|
|
1446
|
+
|
|
1447
|
+
// Thinking indicator (Claude's extended thinking)
|
|
1448
|
+
{ pattern: /thinking|reasoning/i, format: () => '🤔 Thinking' },
|
|
1449
|
+
];
|
|
1450
|
+
|
|
1451
|
+
for (const { pattern, format } of toolPatterns) {
|
|
1452
|
+
const match = line.match(pattern);
|
|
1453
|
+
if (match) {
|
|
1454
|
+
return format(match);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* Format tool name into human-readable action
|
|
1463
|
+
*/
|
|
1464
|
+
formatToolName(toolName) {
|
|
1465
|
+
const toolLabels = {
|
|
1466
|
+
'read': '📄 Reading file',
|
|
1467
|
+
'write': '✏️ Writing file',
|
|
1468
|
+
'edit': '✏️ Editing file',
|
|
1469
|
+
'exec': '⚡ Running command',
|
|
1470
|
+
'browser': '🌐 Using browser',
|
|
1471
|
+
'web_search': '🔍 Searching web',
|
|
1472
|
+
'web_fetch': '🌐 Fetching URL',
|
|
1473
|
+
'memory_search': '🧠 Searching memory',
|
|
1474
|
+
'memory_get': '🧠 Reading memory',
|
|
1475
|
+
'message': '💬 Messaging',
|
|
1476
|
+
'image': '🖼️ Analyzing image',
|
|
1477
|
+
'tts': '🔊 Text to speech',
|
|
1478
|
+
'cron': '⏰ Managing schedule',
|
|
1479
|
+
'nodes': '📱 Using devices',
|
|
1480
|
+
'canvas': '🎨 Using canvas',
|
|
1481
|
+
'sessions_spawn': '🤖 Spawning agent',
|
|
1482
|
+
'sessions_send': '💬 Messaging agent',
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
const lower = toolName.toLowerCase();
|
|
1486
|
+
return toolLabels[lower] || `🔧 ${toolName}`;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Cancel a running agent task by killing the process tree immediately
|
|
1491
|
+
*/
|
|
1492
|
+
cancelAgent(agentId) {
|
|
1493
|
+
const agentInfo = this.activeAgents.get(agentId);
|
|
1494
|
+
if (!agentInfo || !agentInfo.proc) {
|
|
1495
|
+
console.log(`⚠️ No running process found for agent ${agentId}`);
|
|
1496
|
+
return false;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const { proc } = agentInfo;
|
|
1500
|
+
const pid = proc.pid;
|
|
1501
|
+
|
|
1502
|
+
console.log(`🛑 Killing process tree for agent ${agentId} (PID: ${pid})`);
|
|
1503
|
+
|
|
1504
|
+
// Clean up tracking immediately
|
|
1505
|
+
this.activeAgents.delete(agentId);
|
|
1506
|
+
|
|
1507
|
+
// Use tree-kill to kill the entire process tree with SIGKILL (immediate, no grace period)
|
|
1508
|
+
treeKill(pid, 'SIGKILL', (err) => {
|
|
1509
|
+
if (err) {
|
|
1510
|
+
console.log(`⚠️ tree-kill error (process may already be dead): ${err.message}`);
|
|
1511
|
+
} else {
|
|
1512
|
+
console.log(`✅ Process tree ${pid} killed successfully`);
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
this.emit('agent_cancelled', { agentId });
|
|
1517
|
+
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1520
|
+
}
|