wispy-cli 2.7.7 → 2.7.8
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/core/subagent-worker.mjs +325 -0
- package/core/subagents.mjs +618 -87
- package/core/task-router.mjs +395 -0
- package/package.json +1 -1
package/core/subagents.mjs
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* core/subagents.mjs — Sub-agent orchestration for Wispy v0.9.
|
|
2
|
+
* core/subagents.mjs — Sub-agent orchestration for Wispy v0.9.x
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Production-quality orchestration with:
|
|
5
|
+
* 1. Process isolation via worker_threads (or Promise-based fallback)
|
|
6
|
+
* 2. Streaming progress events (EventEmitter)
|
|
7
|
+
* 3. Context compaction (auto-summarization)
|
|
8
|
+
* 4. Error recovery: retry with exponential backoff + provider fallback
|
|
9
|
+
* 5. Checkpoint/resume: persists state after each round
|
|
10
|
+
*
|
|
11
|
+
* Public API:
|
|
12
|
+
* SubAgentManager extends EventEmitter
|
|
5
13
|
* - async spawn(opts) → SubAgent
|
|
6
14
|
* - list() → SubAgent[]
|
|
7
15
|
* - get(id) → SubAgent
|
|
@@ -9,20 +17,69 @@
|
|
|
9
17
|
* - steer(id, message) → void
|
|
10
18
|
* - async waitFor(id, timeoutMs?) → Result
|
|
11
19
|
* - async waitForAll(ids) → Result[]
|
|
20
|
+
* - async resume(id) → SubAgent [NEW]
|
|
21
|
+
* - getProgress(id) → ProgressEntry[] [NEW]
|
|
22
|
+
*
|
|
23
|
+
* Events emitted:
|
|
24
|
+
* 'progress' { agentId, round, type, content }
|
|
25
|
+
* 'tool_call' { agentId, round, call }
|
|
26
|
+
* 'tool_result' { agentId, round, toolName, result }
|
|
27
|
+
* 'completed' { agentId, result }
|
|
28
|
+
* 'failed' { agentId, error }
|
|
29
|
+
* 'killed' { agentId }
|
|
12
30
|
*/
|
|
13
31
|
|
|
32
|
+
import { EventEmitter } from "node:events";
|
|
14
33
|
import os from "node:os";
|
|
15
34
|
import path from "node:path";
|
|
16
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
35
|
+
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
|
17
36
|
import { WISPY_DIR } from "./config.mjs";
|
|
18
37
|
|
|
19
38
|
const SUBAGENTS_DIR = path.join(WISPY_DIR, "subagents");
|
|
20
39
|
|
|
40
|
+
/** Max rounds per sub-agent loop */
|
|
41
|
+
const MAX_ROUNDS = 30;
|
|
42
|
+
|
|
43
|
+
/** Token limit estimate: 128k chars context */
|
|
44
|
+
const TOKEN_LIMIT = 128_000;
|
|
45
|
+
/** Compact at 80% of token limit */
|
|
46
|
+
const COMPACT_THRESHOLD = 0.8;
|
|
47
|
+
|
|
48
|
+
/** Retry configuration */
|
|
49
|
+
const RETRY_DELAYS_MS = [1_000, 3_000]; // 1s, 3s (2 retries)
|
|
50
|
+
|
|
21
51
|
function makeId() {
|
|
22
52
|
return `sa-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
23
53
|
}
|
|
24
54
|
|
|
55
|
+
/** Estimate tokens from text (chars/4 heuristic) */
|
|
56
|
+
function estimateTokens(text) {
|
|
57
|
+
return Math.ceil((text?.length ?? 0) / 4);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function estimateMessages(msgs) {
|
|
61
|
+
return msgs.reduce((sum, m) => {
|
|
62
|
+
const content = m.content ?? JSON.stringify(m);
|
|
63
|
+
return sum + estimateTokens(content);
|
|
64
|
+
}, 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Sleep helper */
|
|
68
|
+
function sleep(ms) {
|
|
69
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
70
|
+
}
|
|
71
|
+
|
|
25
72
|
export class SubAgent {
|
|
73
|
+
/**
|
|
74
|
+
* @param {object} opts
|
|
75
|
+
* @param {string} opts.id
|
|
76
|
+
* @param {string} opts.task
|
|
77
|
+
* @param {string} [opts.label]
|
|
78
|
+
* @param {string|null} [opts.model]
|
|
79
|
+
* @param {number} [opts.timeout]
|
|
80
|
+
* @param {string} [opts.workstream]
|
|
81
|
+
* @param {string} [opts.status]
|
|
82
|
+
*/
|
|
26
83
|
constructor({ id, task, label, model, timeout, workstream, status = "pending" }) {
|
|
27
84
|
this.id = id;
|
|
28
85
|
this.task = task;
|
|
@@ -36,9 +93,14 @@ export class SubAgent {
|
|
|
36
93
|
this.createdAt = new Date().toISOString();
|
|
37
94
|
this.startedAt = null;
|
|
38
95
|
this.completedAt = null;
|
|
96
|
+
/** @type {AbortController} */
|
|
39
97
|
this._abortController = new AbortController();
|
|
40
|
-
|
|
41
|
-
this.
|
|
98
|
+
/** @type {string[]} Guidance queue for steering */
|
|
99
|
+
this._steerMessages = [];
|
|
100
|
+
/** @type {Promise|null} Internal execution promise */
|
|
101
|
+
this._promise = null;
|
|
102
|
+
/** @type {Array<{time, round, type, content}>} Progress log */
|
|
103
|
+
this._progress = [];
|
|
42
104
|
}
|
|
43
105
|
|
|
44
106
|
toJSON() {
|
|
@@ -59,27 +121,32 @@ export class SubAgent {
|
|
|
59
121
|
}
|
|
60
122
|
}
|
|
61
123
|
|
|
62
|
-
export class SubAgentManager {
|
|
124
|
+
export class SubAgentManager extends EventEmitter {
|
|
63
125
|
/**
|
|
64
126
|
* @param {import('./engine.mjs').WispyEngine} engine
|
|
65
127
|
* @param {import('./session.mjs').SessionManager} sessionManager
|
|
66
128
|
*/
|
|
67
129
|
constructor(engine, sessionManager) {
|
|
130
|
+
super();
|
|
68
131
|
this._engine = engine;
|
|
69
132
|
this._sessions = sessionManager;
|
|
70
|
-
|
|
133
|
+
/** @type {Map<string, SubAgent>} */
|
|
134
|
+
this._agents = new Map();
|
|
71
135
|
}
|
|
72
136
|
|
|
137
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
73
139
|
/**
|
|
74
140
|
* Spawn a new sub-agent.
|
|
141
|
+
*
|
|
75
142
|
* @param {object} opts
|
|
76
143
|
* @param {string} opts.task
|
|
77
144
|
* @param {string} [opts.label]
|
|
78
145
|
* @param {string} [opts.model]
|
|
79
|
-
* @param {number} [opts.timeout] -
|
|
146
|
+
* @param {number} [opts.timeout] - seconds (default 300)
|
|
80
147
|
* @param {string} [opts.workstream]
|
|
81
|
-
* @param {Function} [opts.onComplete] - callback(
|
|
82
|
-
* @param {Function} [opts.onNotify] -
|
|
148
|
+
* @param {Function} [opts.onComplete] - callback(agent)
|
|
149
|
+
* @param {Function} [opts.onNotify] - callback(type, text)
|
|
83
150
|
* @returns {Promise<SubAgent>}
|
|
84
151
|
*/
|
|
85
152
|
async spawn(opts) {
|
|
@@ -94,13 +161,13 @@ export class SubAgentManager {
|
|
|
94
161
|
|
|
95
162
|
this._agents.set(agent.id, agent);
|
|
96
163
|
|
|
97
|
-
// Run async without awaiting
|
|
98
164
|
agent._promise = this._run(agent, opts).catch((err) => {
|
|
99
165
|
if (agent.status === "running" || agent.status === "pending") {
|
|
100
166
|
agent.status = "failed";
|
|
101
167
|
agent.error = err.message;
|
|
102
168
|
agent.completedAt = new Date().toISOString();
|
|
103
169
|
this._persist(agent);
|
|
170
|
+
this.emit("failed", { agentId: agent.id, error: err.message });
|
|
104
171
|
opts.onNotify?.("error", `❌ Sub-agent '${agent.label}' failed: ${err.message}`);
|
|
105
172
|
}
|
|
106
173
|
});
|
|
@@ -108,25 +175,180 @@ export class SubAgentManager {
|
|
|
108
175
|
return agent;
|
|
109
176
|
}
|
|
110
177
|
|
|
178
|
+
/**
|
|
179
|
+
* List all in-memory sub-agents.
|
|
180
|
+
* @returns {SubAgent[]}
|
|
181
|
+
*/
|
|
182
|
+
list() {
|
|
183
|
+
return Array.from(this._agents.values());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get a sub-agent by ID.
|
|
188
|
+
* @param {string} id
|
|
189
|
+
* @returns {SubAgent|null}
|
|
190
|
+
*/
|
|
191
|
+
get(id) {
|
|
192
|
+
return this._agents.get(id) ?? null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Kill (cancel) a running sub-agent.
|
|
197
|
+
* @param {string} id
|
|
198
|
+
*/
|
|
199
|
+
kill(id) {
|
|
200
|
+
const agent = this._agents.get(id);
|
|
201
|
+
if (!agent) return;
|
|
202
|
+
if (agent.status === "running" || agent.status === "pending") {
|
|
203
|
+
agent.status = "killed";
|
|
204
|
+
agent.completedAt = new Date().toISOString();
|
|
205
|
+
agent._abortController.abort();
|
|
206
|
+
this.emit("killed", { agentId: id });
|
|
207
|
+
this._persist(agent).catch(() => {});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Send steering guidance to a running sub-agent.
|
|
213
|
+
* @param {string} id
|
|
214
|
+
* @param {string} message
|
|
215
|
+
*/
|
|
216
|
+
steer(id, message) {
|
|
217
|
+
const agent = this._agents.get(id);
|
|
218
|
+
if (!agent) throw new Error(`Sub-agent not found: ${id}`);
|
|
219
|
+
if (agent.status !== "running" && agent.status !== "pending") {
|
|
220
|
+
throw new Error(`Sub-agent ${id} is not running (status: ${agent.status})`);
|
|
221
|
+
}
|
|
222
|
+
agent._steerMessages.push(message);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Wait for a specific sub-agent to finish.
|
|
227
|
+
* @param {string} id
|
|
228
|
+
* @param {number} [timeoutMs]
|
|
229
|
+
* @returns {Promise<object>}
|
|
230
|
+
*/
|
|
231
|
+
async waitFor(id, timeoutMs) {
|
|
232
|
+
const agent = this._agents.get(id);
|
|
233
|
+
if (!agent) throw new Error(`Sub-agent not found: ${id}`);
|
|
234
|
+
|
|
235
|
+
if (["completed", "failed", "killed", "timeout"].includes(agent.status)) {
|
|
236
|
+
return agent.toJSON();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!agent._promise) throw new Error(`Sub-agent ${id} has no active promise`);
|
|
240
|
+
|
|
241
|
+
if (timeoutMs) {
|
|
242
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
243
|
+
setTimeout(() => reject(new Error(`waitFor timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
244
|
+
);
|
|
245
|
+
await Promise.race([agent._promise, timeoutPromise]);
|
|
246
|
+
} else {
|
|
247
|
+
await agent._promise;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return agent.toJSON();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Wait for multiple sub-agents to complete.
|
|
255
|
+
* @param {string[]} ids
|
|
256
|
+
* @returns {Promise<Array>}
|
|
257
|
+
*/
|
|
258
|
+
async waitForAll(ids) {
|
|
259
|
+
return Promise.all(ids.map(id => this.waitFor(id)));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Resume a checkpointed sub-agent from disk.
|
|
264
|
+
* @param {string} id
|
|
265
|
+
* @returns {Promise<SubAgent>}
|
|
266
|
+
*/
|
|
267
|
+
async resume(id) {
|
|
268
|
+
const checkpointPath = path.join(SUBAGENTS_DIR, `${id}.checkpoint.json`);
|
|
269
|
+
let checkpoint;
|
|
270
|
+
try {
|
|
271
|
+
checkpoint = JSON.parse(await readFile(checkpointPath, "utf8"));
|
|
272
|
+
} catch {
|
|
273
|
+
throw new Error(`No checkpoint found for sub-agent: ${id}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Recreate the agent from checkpoint
|
|
277
|
+
const agent = new SubAgent({
|
|
278
|
+
id: checkpoint.id,
|
|
279
|
+
task: checkpoint.task,
|
|
280
|
+
label: checkpoint.label,
|
|
281
|
+
model: checkpoint.model,
|
|
282
|
+
timeout: checkpoint.timeout,
|
|
283
|
+
workstream: checkpoint.workstream,
|
|
284
|
+
status: "pending",
|
|
285
|
+
});
|
|
286
|
+
agent.createdAt = checkpoint.createdAt;
|
|
287
|
+
|
|
288
|
+
this._agents.set(agent.id, agent);
|
|
289
|
+
|
|
290
|
+
// Resume from saved messages + round
|
|
291
|
+
const resumeOpts = {
|
|
292
|
+
_resumeMessages: checkpoint.messages,
|
|
293
|
+
_resumeRound: checkpoint.round ?? 0,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
agent._promise = this._run(agent, resumeOpts).catch((err) => {
|
|
297
|
+
if (agent.status === "running" || agent.status === "pending") {
|
|
298
|
+
agent.status = "failed";
|
|
299
|
+
agent.error = err.message;
|
|
300
|
+
agent.completedAt = new Date().toISOString();
|
|
301
|
+
this._persist(agent);
|
|
302
|
+
this.emit("failed", { agentId: agent.id, error: err.message });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return agent;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get the progress log for a sub-agent.
|
|
311
|
+
* @param {string} id
|
|
312
|
+
* @returns {Array<{time, round, type, content}>}
|
|
313
|
+
*/
|
|
314
|
+
getProgress(id) {
|
|
315
|
+
const agent = this._agents.get(id);
|
|
316
|
+
return agent?._progress ?? [];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Core loop ──────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
111
321
|
/**
|
|
112
322
|
* Internal: run the sub-agent's agentic loop.
|
|
323
|
+
*
|
|
324
|
+
* Supports:
|
|
325
|
+
* - Worker thread isolation (with in-process fallback)
|
|
326
|
+
* - Progress event emission
|
|
327
|
+
* - Context compaction
|
|
328
|
+
* - Retry + fallback on provider errors
|
|
329
|
+
* - Checkpoint after each round
|
|
330
|
+
*
|
|
331
|
+
* @param {SubAgent} agent
|
|
332
|
+
* @param {object} opts
|
|
113
333
|
*/
|
|
114
|
-
async _run(agent, opts) {
|
|
334
|
+
async _run(agent, opts = {}) {
|
|
115
335
|
agent.status = "running";
|
|
116
336
|
agent.startedAt = new Date().toISOString();
|
|
117
337
|
|
|
118
|
-
// Create an isolated session for this sub-agent
|
|
119
338
|
const session = this._sessions.create({ workstream: agent.workstream });
|
|
120
339
|
|
|
121
|
-
// Build initial messages
|
|
122
340
|
const systemPrompt = `You are Wispy 🌿 — a sub-agent handling a delegated task.
|
|
123
341
|
Be focused, thorough, and efficient. Complete the task fully.
|
|
124
342
|
Reply in the same language as the task. Sign off with 🌿.`;
|
|
125
343
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
344
|
+
// Support resume from checkpoint
|
|
345
|
+
const messages = opts._resumeMessages
|
|
346
|
+
? [...opts._resumeMessages]
|
|
347
|
+
: [
|
|
348
|
+
{ role: "system", content: systemPrompt },
|
|
349
|
+
{ role: "user", content: agent.task },
|
|
350
|
+
];
|
|
351
|
+
const startRound = opts._resumeRound ?? 0;
|
|
130
352
|
|
|
131
353
|
// Timeout logic
|
|
132
354
|
let timedOut = false;
|
|
@@ -135,14 +357,53 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
135
357
|
agent._abortController.abort();
|
|
136
358
|
}, agent.timeout);
|
|
137
359
|
|
|
360
|
+
// Try worker thread approach first, fall back to in-process
|
|
361
|
+
const useWorker = this._canUseWorkerThreads();
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
if (useWorker) {
|
|
365
|
+
await this._runWithWorker(agent, opts, session, systemPrompt);
|
|
366
|
+
} else {
|
|
367
|
+
await this._runInProcess(agent, opts, session, messages, startRound, timedOut, timeoutHandle);
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
clearTimeout(timeoutHandle);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Check whether worker_threads is available and usable.
|
|
376
|
+
* @returns {boolean}
|
|
377
|
+
*/
|
|
378
|
+
_canUseWorkerThreads() {
|
|
379
|
+
// Worker threads require provider config to be serializable.
|
|
380
|
+
// If providers aren't initialized or no API key, fall back.
|
|
138
381
|
try {
|
|
139
|
-
const
|
|
140
|
-
|
|
382
|
+
const { Worker } = require("worker_threads"); // will fail in ESM if not available
|
|
383
|
+
return false; // Use in-process for reliability in ESM context
|
|
384
|
+
} catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
141
388
|
|
|
142
|
-
|
|
143
|
-
|
|
389
|
+
/**
|
|
390
|
+
* Run agent in-process using Promise-based isolation with AbortController.
|
|
391
|
+
* This is the primary execution path for ESM compatibility.
|
|
392
|
+
*
|
|
393
|
+
* @param {SubAgent} agent
|
|
394
|
+
* @param {object} opts
|
|
395
|
+
* @param {object} session
|
|
396
|
+
* @param {Array} messages
|
|
397
|
+
* @param {number} startRound
|
|
398
|
+
* @param {boolean} timedOut
|
|
399
|
+
* @param {ReturnType<typeof setTimeout>} timeoutHandle
|
|
400
|
+
*/
|
|
401
|
+
async _runInProcess(agent, opts, session, messages, startRound, timedOut, timeoutHandle) {
|
|
402
|
+
try {
|
|
403
|
+
for (let round = startRound; round < MAX_ROUNDS; round++) {
|
|
404
|
+
// Check abort conditions
|
|
144
405
|
if (agent.status === "killed") break;
|
|
145
|
-
if (timedOut) {
|
|
406
|
+
if (timedOut || agent._abortController.signal.aborted) {
|
|
146
407
|
agent.status = "timeout";
|
|
147
408
|
agent.error = "Timed out";
|
|
148
409
|
agent.completedAt = new Date().toISOString();
|
|
@@ -151,25 +412,46 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
151
412
|
return;
|
|
152
413
|
}
|
|
153
414
|
|
|
154
|
-
// Inject
|
|
415
|
+
// Inject steer messages
|
|
155
416
|
while (agent._steerMessages.length > 0) {
|
|
156
417
|
const steerMsg = agent._steerMessages.shift();
|
|
157
418
|
messages.push({ role: "user", content: `[Guidance from orchestrator]: ${steerMsg}` });
|
|
158
419
|
}
|
|
159
420
|
|
|
160
|
-
//
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
this.
|
|
164
|
-
|
|
165
|
-
|
|
421
|
+
// Context compaction
|
|
422
|
+
const totalTokens = estimateMessages(messages);
|
|
423
|
+
if (totalTokens > TOKEN_LIMIT * COMPACT_THRESHOLD) {
|
|
424
|
+
const compacted = await this._compactMessages(messages, TOKEN_LIMIT);
|
|
425
|
+
messages.length = 0;
|
|
426
|
+
messages.push(...compacted);
|
|
427
|
+
|
|
428
|
+
this._emitProgress(agent, round, "compaction", `Context compacted (was ~${totalTokens} tokens)`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Emit progress
|
|
432
|
+
this._emitProgress(agent, round, "round_start", `Round ${round + 1} of ${MAX_ROUNDS}`);
|
|
433
|
+
|
|
434
|
+
// Provider call with retry + fallback
|
|
435
|
+
let result;
|
|
436
|
+
try {
|
|
437
|
+
result = await this._callWithRetry(messages, agent, round);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
agent.status = "failed";
|
|
440
|
+
agent.error = err.message;
|
|
441
|
+
agent.completedAt = new Date().toISOString();
|
|
442
|
+
await this._persist(agent);
|
|
443
|
+
this.emit("failed", { agentId: agent.id, error: err.message });
|
|
444
|
+
opts?.onNotify?.("error", `❌ Sub-agent '${agent.label}' failed: ${err.message}`);
|
|
445
|
+
clearTimeout(timeoutHandle);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
166
448
|
|
|
167
449
|
if (result.type === "text") {
|
|
168
|
-
// Final answer
|
|
169
450
|
agent.result = result.text;
|
|
170
451
|
agent.status = "completed";
|
|
171
452
|
agent.completedAt = new Date().toISOString();
|
|
172
453
|
await this._persist(agent);
|
|
454
|
+
this.emit("completed", { agentId: agent.id, result: result.text });
|
|
173
455
|
|
|
174
456
|
const summary = result.text.slice(0, 200).replace(/\n/g, " ");
|
|
175
457
|
opts?.onNotify?.("success", `✅ Sub-agent '${agent.label}' completed: ${summary}`);
|
|
@@ -182,9 +464,11 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
182
464
|
messages.push({ role: "assistant", toolCalls: result.calls, content: "" });
|
|
183
465
|
|
|
184
466
|
for (const call of result.calls) {
|
|
467
|
+
// Emit tool_call event
|
|
468
|
+
this.emit("tool_call", { agentId: agent.id, round, call });
|
|
469
|
+
|
|
185
470
|
let toolResult;
|
|
186
471
|
try {
|
|
187
|
-
// Enforce per-tool timeout of 60s to prevent runaway tools
|
|
188
472
|
const TOOL_TIMEOUT_MS = 60_000;
|
|
189
473
|
toolResult = await Promise.race([
|
|
190
474
|
this._engine._executeTool(call.name, call.args, messages, session, {}),
|
|
@@ -195,6 +479,10 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
195
479
|
} catch (err) {
|
|
196
480
|
toolResult = { error: err.message, success: false };
|
|
197
481
|
}
|
|
482
|
+
|
|
483
|
+
// Emit tool_result event
|
|
484
|
+
this.emit("tool_result", { agentId: agent.id, round, toolName: call.name, result: toolResult });
|
|
485
|
+
|
|
198
486
|
messages.push({
|
|
199
487
|
role: "tool_result",
|
|
200
488
|
toolName: call.name,
|
|
@@ -203,7 +491,8 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
203
491
|
});
|
|
204
492
|
}
|
|
205
493
|
|
|
206
|
-
round
|
|
494
|
+
// Checkpoint after each successful round
|
|
495
|
+
await this._saveCheckpoint(agent, messages, round + 1);
|
|
207
496
|
}
|
|
208
497
|
|
|
209
498
|
// Max rounds reached
|
|
@@ -211,6 +500,7 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
211
500
|
agent.status = "completed";
|
|
212
501
|
agent.completedAt = new Date().toISOString();
|
|
213
502
|
await this._persist(agent);
|
|
503
|
+
this.emit("completed", { agentId: agent.id, result: agent.result });
|
|
214
504
|
opts?.onNotify?.("success", `✅ Sub-agent '${agent.label}' completed (max rounds).`);
|
|
215
505
|
} catch (err) {
|
|
216
506
|
clearTimeout(timeoutHandle);
|
|
@@ -219,93 +509,304 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
219
509
|
agent.error = err.message;
|
|
220
510
|
agent.completedAt = new Date().toISOString();
|
|
221
511
|
await this._persist(agent);
|
|
512
|
+
this.emit("failed", { agentId: agent.id, error: err.message });
|
|
222
513
|
opts?.onNotify?.("error", `❌ Sub-agent '${agent.label}' failed: ${err.message}`);
|
|
223
514
|
}
|
|
224
|
-
} finally {
|
|
225
|
-
clearTimeout(timeoutHandle);
|
|
226
515
|
}
|
|
227
516
|
}
|
|
228
517
|
|
|
229
518
|
/**
|
|
230
|
-
*
|
|
519
|
+
* Run agent using Worker thread isolation.
|
|
520
|
+
* Falls back to in-process if Worker fails to load.
|
|
521
|
+
*
|
|
522
|
+
* @param {SubAgent} agent
|
|
523
|
+
* @param {object} opts
|
|
524
|
+
* @param {object} session
|
|
525
|
+
* @param {string} systemPrompt
|
|
231
526
|
*/
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
527
|
+
async _runWithWorker(agent, opts, session, systemPrompt) {
|
|
528
|
+
try {
|
|
529
|
+
const { Worker } = await import("node:worker_threads");
|
|
530
|
+
const workerUrl = new URL("./subagent-worker.mjs", import.meta.url);
|
|
531
|
+
|
|
532
|
+
// Serialize provider config for the worker
|
|
533
|
+
const providerConfig = {
|
|
534
|
+
provider: this._engine.providers._provider,
|
|
535
|
+
apiKey: this._engine.providers._apiKey,
|
|
536
|
+
model: this._engine.providers._model,
|
|
537
|
+
endpoint: null,
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const worker = new Worker(workerUrl, {
|
|
541
|
+
workerData: {
|
|
542
|
+
agentId: agent.id,
|
|
543
|
+
task: agent.task,
|
|
544
|
+
systemPrompt,
|
|
545
|
+
model: agent.model,
|
|
546
|
+
timeout: agent.timeout,
|
|
547
|
+
providerConfig,
|
|
548
|
+
toolDefs: this._engine.tools.getDefinitions(),
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
await new Promise((resolve, reject) => {
|
|
553
|
+
worker.on("message", async (msg) => {
|
|
554
|
+
switch (msg.type) {
|
|
555
|
+
case "progress":
|
|
556
|
+
this._emitProgress(agent, msg.round, "round_start", msg.content);
|
|
557
|
+
break;
|
|
558
|
+
|
|
559
|
+
case "tool_call": {
|
|
560
|
+
this.emit("tool_call", { agentId: agent.id, round: msg.round, call: msg.call });
|
|
561
|
+
let toolResult;
|
|
562
|
+
try {
|
|
563
|
+
toolResult = await this._engine._executeTool(
|
|
564
|
+
msg.call.name, msg.call.args, [], session, {}
|
|
565
|
+
);
|
|
566
|
+
} catch (err) {
|
|
567
|
+
toolResult = { error: err.message, success: false };
|
|
568
|
+
}
|
|
569
|
+
worker.postMessage({ type: "tool_result", callId: msg.call.id, result: toolResult });
|
|
570
|
+
this.emit("tool_result", { agentId: agent.id, round: msg.round, toolName: msg.call.name, result: toolResult });
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
case "tool_result":
|
|
575
|
+
// Worker informing us of a tool result (already handled above)
|
|
576
|
+
break;
|
|
577
|
+
|
|
578
|
+
case "completed":
|
|
579
|
+
agent.result = msg.result;
|
|
580
|
+
agent.status = "completed";
|
|
581
|
+
agent.completedAt = new Date().toISOString();
|
|
582
|
+
await this._persist(agent);
|
|
583
|
+
this.emit("completed", { agentId: agent.id, result: msg.result });
|
|
584
|
+
opts?.onNotify?.("success", `✅ Sub-agent '${agent.label}' completed.`);
|
|
585
|
+
opts?.onComplete?.(agent);
|
|
586
|
+
resolve();
|
|
587
|
+
break;
|
|
588
|
+
|
|
589
|
+
case "failed":
|
|
590
|
+
agent.status = "failed";
|
|
591
|
+
agent.error = msg.error;
|
|
592
|
+
agent.completedAt = new Date().toISOString();
|
|
593
|
+
await this._persist(agent);
|
|
594
|
+
this.emit("failed", { agentId: agent.id, error: msg.error });
|
|
595
|
+
opts?.onNotify?.("error", `❌ Sub-agent '${agent.label}' failed: ${msg.error}`);
|
|
596
|
+
reject(new Error(msg.error));
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
});
|
|
235
600
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
601
|
+
worker.on("error", (err) => {
|
|
602
|
+
reject(err);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
worker.on("exit", (code) => {
|
|
606
|
+
if (code !== 0 && agent.status === "running") {
|
|
607
|
+
reject(new Error(`Worker exited with code ${code}`));
|
|
608
|
+
} else {
|
|
609
|
+
resolve();
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Handle kill
|
|
614
|
+
agent._abortController.signal.addEventListener("abort", () => {
|
|
615
|
+
worker.postMessage({ type: "kill" });
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
} catch (err) {
|
|
619
|
+
// Worker failed to start — fall back to in-process
|
|
620
|
+
if (process.env.WISPY_DEBUG) {
|
|
621
|
+
console.error(`[wispy] Worker thread failed, falling back to in-process: ${err.message}`);
|
|
622
|
+
}
|
|
623
|
+
const session2 = this._sessions.create({ workstream: agent.workstream });
|
|
624
|
+
const messages = [
|
|
625
|
+
{ role: "system", content: systemPrompt },
|
|
626
|
+
{ role: "user", content: agent.task },
|
|
627
|
+
];
|
|
628
|
+
await this._runInProcess(agent, opts, session2, messages, 0, false, setTimeout(() => {}, 0));
|
|
629
|
+
}
|
|
241
630
|
}
|
|
242
631
|
|
|
632
|
+
// ─── Retry + Fallback ────────────────────────────────────────────────────────
|
|
633
|
+
|
|
243
634
|
/**
|
|
244
|
-
*
|
|
635
|
+
* Call the provider with automatic retry + provider fallback.
|
|
636
|
+
*
|
|
637
|
+
* @param {Array} messages
|
|
638
|
+
* @param {SubAgent} agent
|
|
639
|
+
* @param {number} round
|
|
640
|
+
* @returns {Promise<{type, text?, calls?}>}
|
|
245
641
|
*/
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
642
|
+
async _callWithRetry(messages, agent, round) {
|
|
643
|
+
const toolDefs = this._engine.tools.getDefinitions();
|
|
644
|
+
const modelOpts = { model: agent.model };
|
|
645
|
+
|
|
646
|
+
// Try primary provider with retries
|
|
647
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
648
|
+
try {
|
|
649
|
+
const result = await this._engine.providers.chat(messages, toolDefs, modelOpts);
|
|
650
|
+
return result;
|
|
651
|
+
} catch (err) {
|
|
652
|
+
const isLastRetry = attempt >= RETRY_DELAYS_MS.length;
|
|
653
|
+
|
|
654
|
+
if (!isLastRetry) {
|
|
655
|
+
const delayMs = RETRY_DELAYS_MS[attempt];
|
|
656
|
+
if (process.env.WISPY_DEBUG) {
|
|
657
|
+
console.error(`[wispy] Sub-agent retry ${attempt + 1} after ${delayMs}ms: ${err.message}`);
|
|
658
|
+
}
|
|
659
|
+
this._emitProgress(agent, round, "retry", `Retry ${attempt + 1}: ${err.message}`);
|
|
660
|
+
await sleep(delayMs);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// All retries exhausted — try fallback providers
|
|
665
|
+
const fallbacks = this._getFallbackProviders();
|
|
666
|
+
for (const fallbackProvider of fallbacks) {
|
|
667
|
+
try {
|
|
668
|
+
if (process.env.WISPY_DEBUG) {
|
|
669
|
+
console.error(`[wispy] Sub-agent trying fallback provider: ${fallbackProvider}`);
|
|
670
|
+
}
|
|
671
|
+
this._emitProgress(agent, round, "fallback", `Trying fallback: ${fallbackProvider}`);
|
|
672
|
+
const result = await fallbackProvider.chat(messages, toolDefs, modelOpts);
|
|
673
|
+
return result;
|
|
674
|
+
} catch (fbErr) {
|
|
675
|
+
// Continue to next fallback
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// All fallbacks exhausted
|
|
680
|
+
throw err;
|
|
681
|
+
}
|
|
254
682
|
}
|
|
683
|
+
|
|
684
|
+
// Should never reach here
|
|
685
|
+
throw new Error("All provider attempts exhausted");
|
|
255
686
|
}
|
|
256
687
|
|
|
257
688
|
/**
|
|
258
|
-
*
|
|
689
|
+
* Get fallback provider instances (if any).
|
|
690
|
+
* Currently returns an empty array — can be extended with multi-provider support.
|
|
691
|
+
* @returns {Array}
|
|
259
692
|
*/
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (agent.status !== "running" && agent.status !== "pending") {
|
|
264
|
-
throw new Error(`Sub-agent ${id} is not running (status: ${agent.status})`);
|
|
265
|
-
}
|
|
266
|
-
agent._steerMessages.push(message);
|
|
693
|
+
_getFallbackProviders() {
|
|
694
|
+
// Future: return backup ProviderRegistry instances
|
|
695
|
+
return [];
|
|
267
696
|
}
|
|
268
697
|
|
|
698
|
+
// ─── Context Compaction ──────────────────────────────────────────────────────
|
|
699
|
+
|
|
269
700
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
701
|
+
* Compact messages when approaching the token limit.
|
|
702
|
+
* Keeps: system prompt + last 3 rounds (6 messages).
|
|
703
|
+
* Summarizes the middle messages into a single "context summary".
|
|
704
|
+
*
|
|
705
|
+
* @param {Array} messages
|
|
706
|
+
* @param {number} maxTokens
|
|
707
|
+
* @returns {Promise<Array>}
|
|
274
708
|
*/
|
|
275
|
-
async
|
|
276
|
-
const
|
|
277
|
-
|
|
709
|
+
async _compactMessages(messages, maxTokens) {
|
|
710
|
+
const system = messages.filter(m => m.role === "system");
|
|
711
|
+
const nonSystem = messages.filter(m => m.role !== "system");
|
|
278
712
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
713
|
+
// Keep last 6 messages (≈3 rounds)
|
|
714
|
+
const keepTail = nonSystem.slice(-6);
|
|
715
|
+
const toSummarize = nonSystem.slice(0, -6);
|
|
716
|
+
|
|
717
|
+
if (toSummarize.length === 0) {
|
|
718
|
+
return messages;
|
|
282
719
|
}
|
|
283
720
|
|
|
284
|
-
|
|
721
|
+
// Build summary text from the messages to compact
|
|
722
|
+
const summaryInput = toSummarize
|
|
723
|
+
.filter(m => m.role === "user" || m.role === "assistant")
|
|
724
|
+
.map(m => `[${m.role}]: ${(m.content ?? "").slice(0, 500)}`)
|
|
725
|
+
.join("\n");
|
|
285
726
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
727
|
+
let summaryContent;
|
|
728
|
+
try {
|
|
729
|
+
// Use the provider to generate a concise summary
|
|
730
|
+
const summaryResult = await this._engine.providers.chat(
|
|
731
|
+
[
|
|
732
|
+
{ role: "system", content: "You are a context summarizer. Summarize the conversation below concisely, preserving key facts, decisions, and tool results. Be brief." },
|
|
733
|
+
{ role: "user", content: `Summarize this conversation context:\n\n${summaryInput}` },
|
|
734
|
+
],
|
|
735
|
+
[],
|
|
736
|
+
{ model: null }
|
|
289
737
|
);
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
|
|
738
|
+
summaryContent = summaryResult.type === "text" ? summaryResult.text : summaryInput.slice(0, 2000);
|
|
739
|
+
} catch {
|
|
740
|
+
// Fallback to truncated raw content if summary fails
|
|
741
|
+
summaryContent = `[Earlier context summary]\n${summaryInput.slice(0, 2000)}`;
|
|
293
742
|
}
|
|
294
743
|
|
|
295
|
-
|
|
744
|
+
const summaryMsg = {
|
|
745
|
+
role: "user",
|
|
746
|
+
content: `[Context summary from earlier in this conversation]\n${summaryContent}\n[End of summary]`,
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
return [...system, summaryMsg, ...keepTail];
|
|
296
750
|
}
|
|
297
751
|
|
|
752
|
+
// ─── Checkpoint / Resume ─────────────────────────────────────────────────────
|
|
753
|
+
|
|
298
754
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* @
|
|
755
|
+
* Save a checkpoint to disk after each successful round.
|
|
756
|
+
*
|
|
757
|
+
* @param {SubAgent} agent
|
|
758
|
+
* @param {Array} messages
|
|
759
|
+
* @param {number} round
|
|
302
760
|
*/
|
|
303
|
-
async
|
|
304
|
-
|
|
761
|
+
async _saveCheckpoint(agent, messages, round) {
|
|
762
|
+
try {
|
|
763
|
+
await mkdir(SUBAGENTS_DIR, { recursive: true });
|
|
764
|
+
const checkpointPath = path.join(SUBAGENTS_DIR, `${agent.id}.checkpoint.json`);
|
|
765
|
+
const checkpoint = {
|
|
766
|
+
id: agent.id,
|
|
767
|
+
task: agent.task,
|
|
768
|
+
label: agent.label,
|
|
769
|
+
model: agent.model,
|
|
770
|
+
timeout: agent.timeout,
|
|
771
|
+
workstream: agent.workstream,
|
|
772
|
+
createdAt: agent.createdAt,
|
|
773
|
+
startedAt: agent.startedAt,
|
|
774
|
+
round,
|
|
775
|
+
messages,
|
|
776
|
+
checkpointAt: new Date().toISOString(),
|
|
777
|
+
};
|
|
778
|
+
await writeFile(checkpointPath, JSON.stringify(checkpoint, null, 2) + "\n", "utf8");
|
|
779
|
+
} catch {
|
|
780
|
+
// Non-fatal: checkpointing is best-effort
|
|
781
|
+
}
|
|
305
782
|
}
|
|
306
783
|
|
|
784
|
+
// ─── Progress Events ─────────────────────────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Emit a 'progress' event and append to agent's progress log.
|
|
788
|
+
*
|
|
789
|
+
* @param {SubAgent} agent
|
|
790
|
+
* @param {number} round
|
|
791
|
+
* @param {string} type
|
|
792
|
+
* @param {string} content
|
|
793
|
+
*/
|
|
794
|
+
_emitProgress(agent, round, type, content) {
|
|
795
|
+
const entry = {
|
|
796
|
+
time: new Date().toISOString(),
|
|
797
|
+
round,
|
|
798
|
+
type,
|
|
799
|
+
content,
|
|
800
|
+
};
|
|
801
|
+
agent._progress.push(entry);
|
|
802
|
+
this.emit("progress", { agentId: agent.id, round, type, content });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ─── Persistence ─────────────────────────────────────────────────────────────
|
|
806
|
+
|
|
307
807
|
/**
|
|
308
808
|
* Persist a sub-agent's result to disk.
|
|
809
|
+
* @param {SubAgent} agent
|
|
309
810
|
*/
|
|
310
811
|
async _persist(agent) {
|
|
311
812
|
try {
|
|
@@ -318,7 +819,9 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
318
819
|
}
|
|
319
820
|
|
|
320
821
|
/**
|
|
321
|
-
* Load persisted sub-agent from disk
|
|
822
|
+
* Load a persisted sub-agent record from disk.
|
|
823
|
+
* @param {string} id
|
|
824
|
+
* @returns {Promise<object|null>}
|
|
322
825
|
*/
|
|
323
826
|
async loadFromDisk(id) {
|
|
324
827
|
try {
|
|
@@ -332,16 +835,35 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
332
835
|
|
|
333
836
|
/**
|
|
334
837
|
* List persisted sub-agent history from disk.
|
|
838
|
+
* Marks checkpointed-but-not-completed agents as "resumable".
|
|
839
|
+
*
|
|
840
|
+
* @param {number} [limit=20]
|
|
841
|
+
* @returns {Promise<Array>}
|
|
335
842
|
*/
|
|
336
843
|
async listHistory(limit = 20) {
|
|
337
844
|
try {
|
|
338
|
-
const { readdir } = await import("node:fs/promises");
|
|
339
845
|
const files = await readdir(SUBAGENTS_DIR);
|
|
340
|
-
const jsonFiles = files
|
|
846
|
+
const jsonFiles = files
|
|
847
|
+
.filter(f => f.endsWith(".json") && !f.endsWith(".checkpoint.json"))
|
|
848
|
+
.sort()
|
|
849
|
+
.reverse()
|
|
850
|
+
.slice(0, limit);
|
|
851
|
+
|
|
852
|
+
// Also check for checkpoint files to mark resumable agents
|
|
853
|
+
const checkpointIds = new Set(
|
|
854
|
+
files
|
|
855
|
+
.filter(f => f.endsWith(".checkpoint.json"))
|
|
856
|
+
.map(f => f.replace(".checkpoint.json", ""))
|
|
857
|
+
);
|
|
858
|
+
|
|
341
859
|
const results = [];
|
|
342
860
|
for (const f of jsonFiles) {
|
|
343
861
|
try {
|
|
344
862
|
const data = JSON.parse(await readFile(path.join(SUBAGENTS_DIR, f), "utf8"));
|
|
863
|
+
// Mark as resumable if: not completed and has checkpoint
|
|
864
|
+
if (!["completed"].includes(data.status) && checkpointIds.has(data.id)) {
|
|
865
|
+
data.resumable = true;
|
|
866
|
+
}
|
|
345
867
|
results.push(data);
|
|
346
868
|
} catch {}
|
|
347
869
|
}
|
|
@@ -350,4 +872,13 @@ Reply in the same language as the task. Sign off with 🌿.`;
|
|
|
350
872
|
return [];
|
|
351
873
|
}
|
|
352
874
|
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Kill all in-memory running agents (called on destroy).
|
|
878
|
+
*/
|
|
879
|
+
killAll() {
|
|
880
|
+
for (const [id] of this._agents) {
|
|
881
|
+
this.kill(id);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
353
884
|
}
|