wispy-cli 2.7.8 → 2.7.9
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/browser.mjs +327 -0
- package/core/engine.mjs +239 -0
- package/core/subagents.mjs +24 -1
- package/core/task-decomposer.mjs +375 -0
- package/core/task-router.mjs +2 -2
- package/core/tools.mjs +59 -0
- package/package.json +1 -1
package/core/browser.mjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/browser.mjs — Native browser integration via local-browser-bridge
|
|
3
|
+
*
|
|
4
|
+
* Connects to the local-browser-bridge HTTP API for browser control.
|
|
5
|
+
* Supports: health check, capabilities, diagnostics, tabs, attach,
|
|
6
|
+
* navigate, activate, screenshot, sessions.
|
|
7
|
+
*
|
|
8
|
+
* All HTTP calls use native fetch() (Node 18+).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
|
|
14
|
+
const execAsync = promisify(execFile);
|
|
15
|
+
const NOT_RUNNING_MSG =
|
|
16
|
+
"Bridge not running. Start with: npx local-browser-bridge serve";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* BrowserBridge — HTTP client for local-browser-bridge API.
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* const bridge = new BrowserBridge();
|
|
23
|
+
* await bridge.ensureRunning();
|
|
24
|
+
* const session = await bridge.autoAttach();
|
|
25
|
+
* await bridge.navigate("https://example.com");
|
|
26
|
+
*/
|
|
27
|
+
export class BrowserBridge {
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.baseUrl = options.baseUrl ?? "http://127.0.0.1:3000";
|
|
30
|
+
this.defaultBrowser = options.browser ?? "safari";
|
|
31
|
+
this._session = null; // current active session
|
|
32
|
+
this._engine = options.engine ?? null; // optional WispyEngine ref for audit/trust
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Private helpers ──────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async _get(path, params = {}) {
|
|
38
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
39
|
+
for (const [k, v] of Object.entries(params)) {
|
|
40
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(url.toString(), {
|
|
44
|
+
signal: AbortSignal.timeout(10_000),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const body = await res.text().catch(() => "");
|
|
48
|
+
return { error: `HTTP ${res.status}: ${body.slice(0, 200)}` };
|
|
49
|
+
}
|
|
50
|
+
return res.json();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err.name === "AbortError" || err.name === "TimeoutError") {
|
|
53
|
+
return { error: NOT_RUNNING_MSG };
|
|
54
|
+
}
|
|
55
|
+
if (err.code === "ECONNREFUSED" || err.cause?.code === "ECONNREFUSED") {
|
|
56
|
+
return { error: NOT_RUNNING_MSG };
|
|
57
|
+
}
|
|
58
|
+
return { error: err.message ?? NOT_RUNNING_MSG };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async _post(path, body = {}) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
signal: AbortSignal.timeout(30_000),
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const text = await res.text().catch(() => "");
|
|
72
|
+
return { error: `HTTP ${res.status}: ${text.slice(0, 200)}` };
|
|
73
|
+
}
|
|
74
|
+
return res.json();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err.name === "AbortError" || err.name === "TimeoutError") {
|
|
77
|
+
return { error: NOT_RUNNING_MSG };
|
|
78
|
+
}
|
|
79
|
+
if (err.code === "ECONNREFUSED" || err.cause?.code === "ECONNREFUSED") {
|
|
80
|
+
return { error: NOT_RUNNING_MSG };
|
|
81
|
+
}
|
|
82
|
+
return { error: err.message ?? NOT_RUNNING_MSG };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_audit(action, details = {}) {
|
|
87
|
+
if (!this._engine?.audit) return;
|
|
88
|
+
this._engine.audit.log({
|
|
89
|
+
type: "BROWSER_ACTION",
|
|
90
|
+
action,
|
|
91
|
+
...details,
|
|
92
|
+
}).catch(() => {});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_trustLevel() {
|
|
96
|
+
if (!this._engine) return "careful";
|
|
97
|
+
return this._engine.config?.trustLevel ?? this._engine.permissions?.level ?? "careful";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_mayLog(action, detail) {
|
|
101
|
+
const level = this._trustLevel();
|
|
102
|
+
if (level !== "yolo") {
|
|
103
|
+
if (process.env.WISPY_DEBUG) {
|
|
104
|
+
process.stderr.write(`[browser] ${action}: ${detail}\n`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
this._audit(action, { detail });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Health & discovery ───────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/** GET /health → { ok: true } */
|
|
113
|
+
async health() {
|
|
114
|
+
return this._get("/health");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** GET /v1/capabilities?browser=X */
|
|
118
|
+
async capabilities(browser) {
|
|
119
|
+
return this._get("/v1/capabilities", { browser });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** GET /v1/diagnostics?browser=X */
|
|
123
|
+
async diagnostics(browser) {
|
|
124
|
+
return this._get("/v1/diagnostics", { browser });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Tab inspection ───────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** GET /v1/front-tab?browser=X */
|
|
130
|
+
async frontTab(browser) {
|
|
131
|
+
return this._get("/v1/front-tab", { browser: browser ?? this.defaultBrowser });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* GET /v1/tab?browser=X&...
|
|
136
|
+
* query: { browser?, signature?, windowIndex?, tabIndex?, url?, title? }
|
|
137
|
+
*/
|
|
138
|
+
async getTab(query = {}) {
|
|
139
|
+
const params = { browser: query.browser ?? this.defaultBrowser, ...query };
|
|
140
|
+
return this._get("/v1/tab", params);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** GET /v1/tabs?browser=X */
|
|
144
|
+
async listTabs(browser) {
|
|
145
|
+
return this._get("/v1/tabs", { browser: browser ?? this.defaultBrowser });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Session management ───────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* POST /v1/attach { browser, mode?, tabTarget? }
|
|
152
|
+
* Stores the resulting session as this._session.
|
|
153
|
+
*/
|
|
154
|
+
async attach(browser, opts = {}) {
|
|
155
|
+
const body = {
|
|
156
|
+
browser: browser ?? this.defaultBrowser,
|
|
157
|
+
...opts,
|
|
158
|
+
};
|
|
159
|
+
const result = await this._post("/v1/attach", body);
|
|
160
|
+
if (!result.error) {
|
|
161
|
+
this._session = result;
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** GET /v1/sessions */
|
|
167
|
+
async listSessions() {
|
|
168
|
+
return this._get("/v1/sessions");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** GET /v1/sessions/:id */
|
|
172
|
+
async getSession(id) {
|
|
173
|
+
return this._get(`/v1/sessions/${encodeURIComponent(id)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** POST /v1/sessions/:id/resume */
|
|
177
|
+
async resumeSession(id) {
|
|
178
|
+
const result = await this._post(`/v1/sessions/${encodeURIComponent(id)}/resume`);
|
|
179
|
+
if (!result.error) {
|
|
180
|
+
this._session = result;
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Actions (require active session) ─────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Navigate to a URL.
|
|
189
|
+
* Uses session-scoped endpoint if sessionId/this._session is available.
|
|
190
|
+
*/
|
|
191
|
+
async navigate(url, sessionId) {
|
|
192
|
+
this._mayLog("navigate", url);
|
|
193
|
+
|
|
194
|
+
const sid = sessionId ?? this._session?.id ?? this._session?.sessionId;
|
|
195
|
+
if (sid) {
|
|
196
|
+
return this._post(`/v1/sessions/${encodeURIComponent(sid)}/navigate`, { url });
|
|
197
|
+
}
|
|
198
|
+
return this._post("/v1/navigate", { url });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Bring browser tab to front / focus.
|
|
203
|
+
*/
|
|
204
|
+
async activate(sessionId) {
|
|
205
|
+
this._mayLog("activate", sessionId ?? this._session?.id ?? "default");
|
|
206
|
+
|
|
207
|
+
const sid = sessionId ?? this._session?.id ?? this._session?.sessionId;
|
|
208
|
+
if (sid) {
|
|
209
|
+
return this._post(`/v1/sessions/${encodeURIComponent(sid)}/activate`);
|
|
210
|
+
}
|
|
211
|
+
return this._post("/v1/activate");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Take a screenshot.
|
|
216
|
+
* Returns { screenshot: "<base64>" } or { error }
|
|
217
|
+
*/
|
|
218
|
+
async screenshot(sessionId) {
|
|
219
|
+
const sid = sessionId ?? this._session?.id ?? this._session?.sessionId;
|
|
220
|
+
if (sid) {
|
|
221
|
+
// Some implementations support GET, some POST
|
|
222
|
+
return this._get(`/v1/sessions/${encodeURIComponent(sid)}/screenshot`);
|
|
223
|
+
}
|
|
224
|
+
return this._post("/v1/screenshot");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check health, attempt to start bridge if not running.
|
|
231
|
+
* Returns { ok: boolean, started: boolean }
|
|
232
|
+
*/
|
|
233
|
+
async ensureRunning() {
|
|
234
|
+
// 1. Try health
|
|
235
|
+
const h = await this.health();
|
|
236
|
+
if (h?.ok || (!h?.error && h !== null)) {
|
|
237
|
+
return { ok: true, started: false };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2. Try to start the bridge
|
|
241
|
+
let started = false;
|
|
242
|
+
try {
|
|
243
|
+
execFile("/bin/sh", ["-c", "npx local-browser-bridge serve --port 3000 &"], {
|
|
244
|
+
detached: true,
|
|
245
|
+
stdio: "ignore",
|
|
246
|
+
});
|
|
247
|
+
started = true;
|
|
248
|
+
} catch {
|
|
249
|
+
// ignore — may not have npx or may already be running
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 3. Wait up to 5 seconds for health to respond
|
|
253
|
+
const deadline = Date.now() + 5000;
|
|
254
|
+
while (Date.now() < deadline) {
|
|
255
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
256
|
+
const h2 = await this.health();
|
|
257
|
+
if (h2?.ok || (!h2?.error && h2 !== null)) {
|
|
258
|
+
return { ok: true, started };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { ok: false, started };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Auto-discover and attach to the best available browser.
|
|
267
|
+
* Order: safari-actionable > safari-readonly > chrome-actionable > chrome-readonly
|
|
268
|
+
*/
|
|
269
|
+
async autoAttach() {
|
|
270
|
+
// 1. Get capabilities
|
|
271
|
+
const caps = await this.capabilities();
|
|
272
|
+
if (caps?.error) return { error: caps.error };
|
|
273
|
+
|
|
274
|
+
const browsers = Array.isArray(caps?.browsers)
|
|
275
|
+
? caps.browsers
|
|
276
|
+
: Object.keys(caps ?? {}).filter((k) => k !== "error");
|
|
277
|
+
|
|
278
|
+
if (browsers.length === 0) {
|
|
279
|
+
return { error: "No browsers available via local-browser-bridge" };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 2. Get diagnostics for each browser
|
|
283
|
+
const diagResults = [];
|
|
284
|
+
for (const browser of browsers) {
|
|
285
|
+
const d = await this.diagnostics(browser);
|
|
286
|
+
diagResults.push({ browser, diag: d });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 3. Pick the best actionable browser
|
|
290
|
+
const priority = [
|
|
291
|
+
(d) => d.browser === "safari" && d.diag?.actionable === true,
|
|
292
|
+
(d) => d.browser === "chrome" && d.diag?.actionable === true,
|
|
293
|
+
(d) => d.diag?.actionable === true,
|
|
294
|
+
(d) => d.browser === "safari",
|
|
295
|
+
(d) => true,
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
let chosen = null;
|
|
299
|
+
for (const pred of priority) {
|
|
300
|
+
chosen = diagResults.find(pred);
|
|
301
|
+
if (chosen) break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!chosen) return { error: "No suitable browser found" };
|
|
305
|
+
|
|
306
|
+
// 4. Attach
|
|
307
|
+
const session = await this.attach(chosen.browser);
|
|
308
|
+
return session;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Summary status for display */
|
|
312
|
+
status() {
|
|
313
|
+
return {
|
|
314
|
+
baseUrl: this.baseUrl,
|
|
315
|
+
defaultBrowser: this.defaultBrowser,
|
|
316
|
+
session: this._session
|
|
317
|
+
? {
|
|
318
|
+
id: this._session.id ?? this._session.sessionId,
|
|
319
|
+
browser: this._session.browser,
|
|
320
|
+
mode: this._session.mode,
|
|
321
|
+
}
|
|
322
|
+
: null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export default BrowserBridge;
|
package/core/engine.mjs
CHANGED
|
@@ -27,6 +27,9 @@ import { Harness } from "./harness.mjs";
|
|
|
27
27
|
import { SyncManager, getSyncManager } from "./sync.mjs";
|
|
28
28
|
import { SkillManager } from "./skills.mjs";
|
|
29
29
|
import { UserModel } from "./user-model.mjs";
|
|
30
|
+
import { routeTask, classifyTask, filterAvailableModels } from "./task-router.mjs";
|
|
31
|
+
import { decomposeTask, executeDecomposedPlan } from "./task-decomposer.mjs";
|
|
32
|
+
import { BrowserBridge } from "./browser.mjs";
|
|
30
33
|
|
|
31
34
|
const MAX_TOOL_ROUNDS = 10;
|
|
32
35
|
const MAX_CONTEXT_CHARS = 40_000;
|
|
@@ -46,6 +49,7 @@ export class WispyEngine {
|
|
|
46
49
|
this.sync = null; // SyncManager, initialized lazily
|
|
47
50
|
this.skills = new SkillManager(WISPY_DIR, this);
|
|
48
51
|
this.userModel = new UserModel(WISPY_DIR, this);
|
|
52
|
+
this.browser = new BrowserBridge({ engine: this });
|
|
49
53
|
this._initialized = false;
|
|
50
54
|
this._workMdContent = null;
|
|
51
55
|
this._workMdLoaded = false;
|
|
@@ -87,9 +91,15 @@ export class WispyEngine {
|
|
|
87
91
|
// Register skill tools
|
|
88
92
|
this._registerSkillTools();
|
|
89
93
|
|
|
94
|
+
// Register routing + decomposition tools (v2.8)
|
|
95
|
+
this._registerRoutingTools();
|
|
96
|
+
|
|
90
97
|
// Re-wire harness after tools are registered
|
|
91
98
|
this.harness = new Harness(this.tools, this.permissions, this.audit, this.config);
|
|
92
99
|
|
|
100
|
+
// Start browser bridge (non-blocking, don't fail if unavailable)
|
|
101
|
+
this.browser.ensureRunning().catch(() => {});
|
|
102
|
+
|
|
93
103
|
// Initialize MCP
|
|
94
104
|
if (!opts.skipMcp) {
|
|
95
105
|
await ensureDefaultMcpConfig(this.mcpManager.configPath);
|
|
@@ -347,6 +357,11 @@ export class WispyEngine {
|
|
|
347
357
|
"node_list", "node_status", "node_execute",
|
|
348
358
|
"update_work_context",
|
|
349
359
|
"run_skill", "list_skills", "delete_skill",
|
|
360
|
+
"smart_route", "decompose_and_execute",
|
|
361
|
+
// Browser tools (v2.9)
|
|
362
|
+
"browser_status", "browser_tabs", "browser_navigate",
|
|
363
|
+
"browser_screenshot", "browser_front_tab", "browser_activate",
|
|
364
|
+
"browser_attach",
|
|
350
365
|
]);
|
|
351
366
|
|
|
352
367
|
const harnessResult = await this.harness.execute(name, args, {
|
|
@@ -422,6 +437,26 @@ export class WispyEngine {
|
|
|
422
437
|
return this._toolListSkills(args);
|
|
423
438
|
case "delete_skill":
|
|
424
439
|
return this._toolDeleteSkill(args);
|
|
440
|
+
// Smart routing + decomposition (v2.8)
|
|
441
|
+
case "smart_route":
|
|
442
|
+
return this._toolSmartRoute(args);
|
|
443
|
+
case "decompose_and_execute":
|
|
444
|
+
return this._toolDecomposeAndExecute(args, opts);
|
|
445
|
+
// Browser tools (v2.9)
|
|
446
|
+
case "browser_status":
|
|
447
|
+
return this._toolBrowserStatus();
|
|
448
|
+
case "browser_tabs":
|
|
449
|
+
return this._toolBrowserTabs(args);
|
|
450
|
+
case "browser_navigate":
|
|
451
|
+
return this._toolBrowserNavigate(args);
|
|
452
|
+
case "browser_screenshot":
|
|
453
|
+
return this._toolBrowserScreenshot(args);
|
|
454
|
+
case "browser_front_tab":
|
|
455
|
+
return this._toolBrowserFrontTab(args);
|
|
456
|
+
case "browser_activate":
|
|
457
|
+
return this._toolBrowserActivate(args);
|
|
458
|
+
case "browser_attach":
|
|
459
|
+
return this._toolBrowserAttach(args);
|
|
425
460
|
default:
|
|
426
461
|
return this.tools.execute(name, args);
|
|
427
462
|
}
|
|
@@ -1191,6 +1226,210 @@ export class WispyEngine {
|
|
|
1191
1226
|
return this.skills.delete(args.name);
|
|
1192
1227
|
}
|
|
1193
1228
|
|
|
1229
|
+
// ── Smart routing + decomposition tools (v2.8) ───────────────────────────────
|
|
1230
|
+
|
|
1231
|
+
_registerRoutingTools() {
|
|
1232
|
+
const routingTools = [
|
|
1233
|
+
{
|
|
1234
|
+
name: "smart_route",
|
|
1235
|
+
description: "Analyze a task and recommend the best model to handle it based on task type, complexity, and available providers.",
|
|
1236
|
+
parameters: {
|
|
1237
|
+
type: "object",
|
|
1238
|
+
properties: {
|
|
1239
|
+
task: { type: "string", description: "The task to analyze and route" },
|
|
1240
|
+
cost_preference: {
|
|
1241
|
+
type: "string",
|
|
1242
|
+
enum: ["minimize", "balanced", "maximize-quality"],
|
|
1243
|
+
description: "Tradeoff preference (default: balanced)",
|
|
1244
|
+
},
|
|
1245
|
+
},
|
|
1246
|
+
required: ["task"],
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
{
|
|
1250
|
+
name: "decompose_and_execute",
|
|
1251
|
+
description: "Split a complex task into subtasks, route each to the best model, execute in parallel, and synthesize results into a coherent response.",
|
|
1252
|
+
parameters: {
|
|
1253
|
+
type: "object",
|
|
1254
|
+
properties: {
|
|
1255
|
+
task: { type: "string", description: "The complex task to decompose and execute" },
|
|
1256
|
+
max_subtasks: { type: "number", description: "Maximum number of subtasks (default: 5)" },
|
|
1257
|
+
cost_preference: {
|
|
1258
|
+
type: "string",
|
|
1259
|
+
enum: ["minimize", "balanced", "maximize-quality"],
|
|
1260
|
+
description: "Cost/quality tradeoff preference (default: balanced)",
|
|
1261
|
+
},
|
|
1262
|
+
},
|
|
1263
|
+
required: ["task"],
|
|
1264
|
+
},
|
|
1265
|
+
},
|
|
1266
|
+
];
|
|
1267
|
+
|
|
1268
|
+
for (const tool of routingTools) {
|
|
1269
|
+
this.tools._definitions.set(tool.name, tool);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
async _toolSmartRoute(args) {
|
|
1274
|
+
try {
|
|
1275
|
+
const classification = classifyTask(args.task ?? "");
|
|
1276
|
+
const routing = routeTask(args.task ?? "", null, {
|
|
1277
|
+
costPreference: args.cost_preference ?? "balanced",
|
|
1278
|
+
defaultModel: this.model,
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
return {
|
|
1282
|
+
success: true,
|
|
1283
|
+
recommendation: routing,
|
|
1284
|
+
classification,
|
|
1285
|
+
availableModels: filterAvailableModels(
|
|
1286
|
+
["gpt-5.4", "gpt-4o", "gpt-4o-mini", "claude-opus-4-20250514", "claude-sonnet-4-20250514",
|
|
1287
|
+
"claude-3-5-haiku-20241022", "gemini-2.5-pro", "gemini-2.5-flash",
|
|
1288
|
+
"llama-3.3-70b-versatile", "deepseek-chat", "deepseek-reasoner"]
|
|
1289
|
+
),
|
|
1290
|
+
};
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
return { success: false, error: err.message };
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
async _toolDecomposeAndExecute(args, parentOpts = {}) {
|
|
1297
|
+
try {
|
|
1298
|
+
const task = args.task ?? "";
|
|
1299
|
+
const maxSubtasks = args.max_subtasks ?? 5;
|
|
1300
|
+
const costPreference = args.cost_preference ?? "balanced";
|
|
1301
|
+
|
|
1302
|
+
// Step 1: Decompose
|
|
1303
|
+
const plan = await decomposeTask(task, {
|
|
1304
|
+
maxSubtasks,
|
|
1305
|
+
costPreference,
|
|
1306
|
+
engine: this,
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
if (plan.subtasks.length === 0) {
|
|
1310
|
+
return { success: false, error: "Failed to decompose task into subtasks" };
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Step 2: Execute
|
|
1314
|
+
const executionResult = await executeDecomposedPlan(plan, this, {
|
|
1315
|
+
costPreference,
|
|
1316
|
+
onSubtaskStart: (st) => {
|
|
1317
|
+
if (process.env.WISPY_DEBUG) {
|
|
1318
|
+
console.error(`[decompose] Starting subtask ${st.id}: ${st.task.slice(0, 60)}`);
|
|
1319
|
+
}
|
|
1320
|
+
},
|
|
1321
|
+
onSubtaskComplete: (st, result) => {
|
|
1322
|
+
if (process.env.WISPY_DEBUG) {
|
|
1323
|
+
console.error(`[decompose] Completed subtask ${st.id}`);
|
|
1324
|
+
}
|
|
1325
|
+
},
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
return {
|
|
1329
|
+
success: true,
|
|
1330
|
+
plan: {
|
|
1331
|
+
subtaskCount: plan.subtasks.length,
|
|
1332
|
+
parallelGroups: plan.parallelGroups.length,
|
|
1333
|
+
estimatedCost: plan.estimatedCost,
|
|
1334
|
+
estimatedTime: plan.estimatedTime,
|
|
1335
|
+
},
|
|
1336
|
+
subtasks: executionResult.results.map(r => ({
|
|
1337
|
+
id: r.id,
|
|
1338
|
+
type: r.type,
|
|
1339
|
+
task: r.task.slice(0, 100),
|
|
1340
|
+
resultPreview: r.result?.slice(0, 200) ?? null,
|
|
1341
|
+
})),
|
|
1342
|
+
errors: executionResult.errors,
|
|
1343
|
+
synthesized: executionResult.synthesized,
|
|
1344
|
+
};
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
return { success: false, error: err.message };
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// ── Browser tools (v2.9) ─────────────────────────────────────────────────────
|
|
1351
|
+
|
|
1352
|
+
async _toolBrowserStatus() {
|
|
1353
|
+
try {
|
|
1354
|
+
const health = await this.browser.health();
|
|
1355
|
+
const status = this.browser.status();
|
|
1356
|
+
return {
|
|
1357
|
+
success: true,
|
|
1358
|
+
health: health?.ok ?? false,
|
|
1359
|
+
bridgeUrl: this.browser.baseUrl,
|
|
1360
|
+
session: status.session,
|
|
1361
|
+
hint: health?.error ? `Start bridge: npx local-browser-bridge serve` : undefined,
|
|
1362
|
+
};
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
return { success: false, error: err.message };
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
async _toolBrowserTabs(args) {
|
|
1369
|
+
try {
|
|
1370
|
+
const result = await this.browser.listTabs(args?.browser);
|
|
1371
|
+
if (result?.error) return { success: false, error: result.error };
|
|
1372
|
+
return { success: true, tabs: result?.tabs ?? result };
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
return { success: false, error: err.message };
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
async _toolBrowserNavigate(args) {
|
|
1379
|
+
try {
|
|
1380
|
+
const result = await this.browser.navigate(args.url);
|
|
1381
|
+
if (result?.error) return { success: false, error: result.error };
|
|
1382
|
+
return { success: true, url: args.url, result };
|
|
1383
|
+
} catch (err) {
|
|
1384
|
+
return { success: false, error: err.message };
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async _toolBrowserScreenshot(args) {
|
|
1389
|
+
try {
|
|
1390
|
+
const result = await this.browser.screenshot(args?.sessionId);
|
|
1391
|
+
if (result?.error) return { success: false, error: result.error };
|
|
1392
|
+
return { success: true, screenshot: result?.screenshot ?? result?.data, format: "base64" };
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
return { success: false, error: err.message };
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
async _toolBrowserFrontTab(args) {
|
|
1399
|
+
try {
|
|
1400
|
+
const result = await this.browser.frontTab(args?.browser);
|
|
1401
|
+
if (result?.error) return { success: false, error: result.error };
|
|
1402
|
+
return { success: true, tab: result };
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
return { success: false, error: err.message };
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async _toolBrowserActivate(args) {
|
|
1409
|
+
try {
|
|
1410
|
+
const result = await this.browser.activate(args?.sessionId);
|
|
1411
|
+
if (result?.error) return { success: false, error: result.error };
|
|
1412
|
+
return { success: true, result };
|
|
1413
|
+
} catch (err) {
|
|
1414
|
+
return { success: false, error: err.message };
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async _toolBrowserAttach(args) {
|
|
1419
|
+
try {
|
|
1420
|
+
let result;
|
|
1421
|
+
if (!args?.browser) {
|
|
1422
|
+
result = await this.browser.autoAttach();
|
|
1423
|
+
} else {
|
|
1424
|
+
result = await this.browser.attach(args.browser, { mode: args.mode });
|
|
1425
|
+
}
|
|
1426
|
+
if (result?.error) return { success: false, error: result.error };
|
|
1427
|
+
return { success: true, session: result };
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
return { success: false, error: err.message };
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1194
1433
|
// ── Cleanup ──────────────────────────────────────────────────────────────────
|
|
1195
1434
|
|
|
1196
1435
|
destroy() {
|
package/core/subagents.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import os from "node:os";
|
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
|
36
36
|
import { WISPY_DIR } from "./config.mjs";
|
|
37
|
+
import { routeTask, filterAvailableModels, MODEL_CAPABILITIES, getAvailableProviders } from "./task-router.mjs";
|
|
37
38
|
|
|
38
39
|
const SUBAGENTS_DIR = path.join(WISPY_DIR, "subagents");
|
|
39
40
|
|
|
@@ -150,11 +151,33 @@ export class SubAgentManager extends EventEmitter {
|
|
|
150
151
|
* @returns {Promise<SubAgent>}
|
|
151
152
|
*/
|
|
152
153
|
async spawn(opts) {
|
|
154
|
+
// ── routingPreference: resolve model before spawning ──────────────────────
|
|
155
|
+
let resolvedModel = opts.model ?? null;
|
|
156
|
+
const routingPref = opts.routingPreference ?? "inherit";
|
|
157
|
+
|
|
158
|
+
if (routingPref === "auto") {
|
|
159
|
+
try {
|
|
160
|
+
const routing = routeTask(opts.task ?? "", null, { costPreference: "balanced" });
|
|
161
|
+
resolvedModel = routing.model;
|
|
162
|
+
} catch { /* ignore routing errors, use null */ }
|
|
163
|
+
} else if (routingPref === "fast") {
|
|
164
|
+
try {
|
|
165
|
+
const routing = routeTask(opts.task ?? "", null, { costPreference: "minimize" });
|
|
166
|
+
resolvedModel = routing.model;
|
|
167
|
+
} catch {}
|
|
168
|
+
} else if (routingPref === "quality") {
|
|
169
|
+
try {
|
|
170
|
+
const routing = routeTask(opts.task ?? "", null, { costPreference: "maximize-quality" });
|
|
171
|
+
resolvedModel = routing.model;
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
// "inherit" → use opts.model as-is (or null = parent's model)
|
|
175
|
+
|
|
153
176
|
const agent = new SubAgent({
|
|
154
177
|
id: makeId(),
|
|
155
178
|
task: opts.task,
|
|
156
179
|
label: opts.label,
|
|
157
|
-
model:
|
|
180
|
+
model: resolvedModel,
|
|
158
181
|
timeout: opts.timeout ? opts.timeout * 1000 : 300_000,
|
|
159
182
|
workstream: opts.workstream ?? this._engine._activeWorkstream,
|
|
160
183
|
});
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/task-decomposer.mjs — Task Decomposition Engine for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Splits complex tasks into parallel subtasks, routes each to the best model,
|
|
5
|
+
* executes concurrently, and synthesizes results.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { routeTask, getCheapDecomposerModel, classifyTask } from "./task-router.mjs";
|
|
9
|
+
|
|
10
|
+
// ── Subtask counter ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
let _subtaskCounter = 0;
|
|
13
|
+
function makeSubtaskId() {
|
|
14
|
+
return `st-${(++_subtaskCounter).toString().padStart(2, "0")}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Decompose task using LLM ─────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decompose a complex task into parallel subtasks using a cheap LLM.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} task - The task description
|
|
23
|
+
* @param {object} [options]
|
|
24
|
+
* @param {number} [options.maxSubtasks=5] - Max number of subtasks
|
|
25
|
+
* @param {string} [options.costPreference="balanced"] - "minimize" | "balanced" | "maximize-quality"
|
|
26
|
+
* @param {object} [options.engine] - WispyEngine instance (for LLM calls)
|
|
27
|
+
* @returns {Promise<{
|
|
28
|
+
* subtasks: Array<{id,task,type,dependencies,priority}>,
|
|
29
|
+
* parallelGroups: string[][],
|
|
30
|
+
* estimatedCost: string,
|
|
31
|
+
* estimatedTime: string
|
|
32
|
+
* }>}
|
|
33
|
+
*/
|
|
34
|
+
export async function decomposeTask(task, options = {}) {
|
|
35
|
+
const maxSubtasks = options.maxSubtasks ?? 5;
|
|
36
|
+
const costPreference = options.costPreference ?? "balanced";
|
|
37
|
+
const engine = options.engine ?? null;
|
|
38
|
+
|
|
39
|
+
_subtaskCounter = 0; // reset for this decomposition
|
|
40
|
+
|
|
41
|
+
// Quick path: if the task seems simple, don't bother decomposing
|
|
42
|
+
const classification = classifyTask(task);
|
|
43
|
+
if (classification.complexity !== "complex" || !classification.parallelizable) {
|
|
44
|
+
const subtaskId = makeSubtaskId();
|
|
45
|
+
return {
|
|
46
|
+
subtasks: [
|
|
47
|
+
{
|
|
48
|
+
id: subtaskId,
|
|
49
|
+
task,
|
|
50
|
+
type: classification.type,
|
|
51
|
+
dependencies: [],
|
|
52
|
+
priority: 1,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
parallelGroups: [[subtaskId]],
|
|
56
|
+
estimatedCost: costPreference === "minimize" ? "very-low" : "low",
|
|
57
|
+
estimatedTime: classification.complexity === "simple" ? "<1min" : "1-2min",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Use LLM decomposition if engine is available
|
|
62
|
+
if (engine) {
|
|
63
|
+
try {
|
|
64
|
+
return await _llmDecompose(task, maxSubtasks, costPreference, engine);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (process.env.WISPY_DEBUG) {
|
|
67
|
+
console.error(`[task-decomposer] LLM decompose failed: ${err.message}, falling back to heuristic`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Heuristic decomposition (no LLM)
|
|
73
|
+
return _heuristicDecompose(task, maxSubtasks, costPreference, classification);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Decompose using an LLM call (cheap model).
|
|
78
|
+
*/
|
|
79
|
+
async function _llmDecompose(task, maxSubtasks, costPreference, engine) {
|
|
80
|
+
const { model } = getCheapDecomposerModel();
|
|
81
|
+
|
|
82
|
+
const systemPrompt = `You are a task decomposition expert. Split complex tasks into independent subtasks that can be parallelized.
|
|
83
|
+
Reply with ONLY valid JSON. No markdown, no explanation.`;
|
|
84
|
+
|
|
85
|
+
const userPrompt = `Decompose this task into at most ${maxSubtasks} subtasks.
|
|
86
|
+
Task: "${task}"
|
|
87
|
+
|
|
88
|
+
Requirements:
|
|
89
|
+
- Identify subtasks that can run in parallel (no dependencies)
|
|
90
|
+
- Identify subtasks that need results from others (add dependency IDs)
|
|
91
|
+
- Classify each subtask type: coding, research, analysis, design, review, summarize, format, or general
|
|
92
|
+
- Set priority: 1 = first group (parallel), 2 = waits for priority 1, etc.
|
|
93
|
+
|
|
94
|
+
Respond with ONLY this JSON (no markdown):
|
|
95
|
+
{
|
|
96
|
+
"subtasks": [
|
|
97
|
+
{"id": "st-01", "task": "...", "type": "coding", "dependencies": [], "priority": 1},
|
|
98
|
+
{"id": "st-02", "task": "...", "type": "research", "dependencies": [], "priority": 1},
|
|
99
|
+
{"id": "st-03", "task": "...", "type": "review", "dependencies": ["st-01","st-02"], "priority": 2}
|
|
100
|
+
],
|
|
101
|
+
"estimatedTime": "2-5min",
|
|
102
|
+
"estimatedCost": "low"
|
|
103
|
+
}`;
|
|
104
|
+
|
|
105
|
+
const messages = [
|
|
106
|
+
{ role: "system", content: systemPrompt },
|
|
107
|
+
{ role: "user", content: userPrompt },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const result = await engine.providers.chat(messages, [], { model });
|
|
111
|
+
const text = result.type === "text" ? result.text : JSON.stringify(result);
|
|
112
|
+
|
|
113
|
+
// Extract JSON from response
|
|
114
|
+
const jsonMatch = text.match(/\{[\s\S]*"subtasks"[\s\S]*\}/);
|
|
115
|
+
if (!jsonMatch) throw new Error("LLM did not return valid decomposition JSON");
|
|
116
|
+
|
|
117
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
118
|
+
const subtasks = (parsed.subtasks ?? []).slice(0, maxSubtasks);
|
|
119
|
+
|
|
120
|
+
// Ensure IDs are consistent
|
|
121
|
+
const idMap = {};
|
|
122
|
+
subtasks.forEach((st, i) => {
|
|
123
|
+
const newId = makeSubtaskId();
|
|
124
|
+
idMap[st.id] = newId;
|
|
125
|
+
st.id = newId;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Remap dependency IDs
|
|
129
|
+
for (const st of subtasks) {
|
|
130
|
+
st.dependencies = (st.dependencies ?? []).map(d => idMap[d] ?? d).filter(d => subtasks.some(s => s.id === d));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build parallel groups from priority
|
|
134
|
+
const groups = {};
|
|
135
|
+
for (const st of subtasks) {
|
|
136
|
+
const p = st.priority ?? 1;
|
|
137
|
+
if (!groups[p]) groups[p] = [];
|
|
138
|
+
groups[p].push(st.id);
|
|
139
|
+
}
|
|
140
|
+
const parallelGroups = Object.keys(groups).sort((a, b) => Number(a) - Number(b)).map(k => groups[k]);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
subtasks,
|
|
144
|
+
parallelGroups,
|
|
145
|
+
estimatedCost: parsed.estimatedCost ?? _estimateCost(costPreference, subtasks.length),
|
|
146
|
+
estimatedTime: parsed.estimatedTime ?? _estimateTime(subtasks.length),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Heuristic decomposition (no LLM required).
|
|
152
|
+
*/
|
|
153
|
+
function _heuristicDecompose(task, maxSubtasks, costPreference, classification) {
|
|
154
|
+
// Split by "and" / newlines / semicolons as a heuristic
|
|
155
|
+
const sentences = task
|
|
156
|
+
.split(/\n|;|\band\b(?=[^,]*,|\s+\w+\s+the\s)/)
|
|
157
|
+
.map(s => s.trim())
|
|
158
|
+
.filter(s => s.length > 10);
|
|
159
|
+
|
|
160
|
+
const subtasks = sentences.slice(0, maxSubtasks).map((s, i) => ({
|
|
161
|
+
id: makeSubtaskId(),
|
|
162
|
+
task: s,
|
|
163
|
+
type: classifyTask(s).type,
|
|
164
|
+
dependencies: [],
|
|
165
|
+
priority: 1,
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
// If no useful split found, use the whole task as one subtask
|
|
169
|
+
if (subtasks.length === 0) {
|
|
170
|
+
const id = makeSubtaskId();
|
|
171
|
+
return {
|
|
172
|
+
subtasks: [{ id, task, type: classification.type, dependencies: [], priority: 1 }],
|
|
173
|
+
parallelGroups: [[id]],
|
|
174
|
+
estimatedCost: _estimateCost(costPreference, 1),
|
|
175
|
+
estimatedTime: "1-3min",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
subtasks,
|
|
181
|
+
parallelGroups: [subtasks.map(s => s.id)],
|
|
182
|
+
estimatedCost: _estimateCost(costPreference, subtasks.length),
|
|
183
|
+
estimatedTime: _estimateTime(subtasks.length),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _estimateCost(costPreference, numSubtasks) {
|
|
188
|
+
if (costPreference === "minimize") return "very-low";
|
|
189
|
+
if (costPreference === "maximize-quality") return numSubtasks > 3 ? "high" : "medium";
|
|
190
|
+
return numSubtasks > 3 ? "medium" : "low";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _estimateTime(numSubtasks) {
|
|
194
|
+
if (numSubtasks <= 1) return "<1min";
|
|
195
|
+
if (numSubtasks <= 3) return "1-3min";
|
|
196
|
+
return "2-5min";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Execute decomposed plan ──────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Execute an execution plan, running parallel groups concurrently.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} plan - Result from decomposeTask()
|
|
205
|
+
* @param {object} engine - WispyEngine instance
|
|
206
|
+
* @param {object} [opts]
|
|
207
|
+
* @param {string} [opts.costPreference="balanced"]
|
|
208
|
+
* @param {Function} [opts.onSubtaskStart] - (subtask) => void
|
|
209
|
+
* @param {Function} [opts.onSubtaskComplete] - (subtask, result) => void
|
|
210
|
+
* @param {Function} [opts.onSubtaskFail] - (subtask, error) => void
|
|
211
|
+
* @returns {Promise<{ results: object[], synthesized: string, errors: object[] }>}
|
|
212
|
+
*/
|
|
213
|
+
export async function executeDecomposedPlan(plan, engine, opts = {}) {
|
|
214
|
+
const costPreference = opts.costPreference ?? "balanced";
|
|
215
|
+
const completedResults = {}; // id → result
|
|
216
|
+
const errors = [];
|
|
217
|
+
const MAX_RETRIES = 1;
|
|
218
|
+
|
|
219
|
+
for (const group of plan.parallelGroups) {
|
|
220
|
+
// Filter to subtasks in this group (skip if all deps not satisfied)
|
|
221
|
+
const groupSubtasks = group
|
|
222
|
+
.map(id => plan.subtasks.find(s => s.id === id))
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
|
|
225
|
+
// Run group in parallel
|
|
226
|
+
const groupPromises = groupSubtasks.map(async (subtask) => {
|
|
227
|
+
// Route to best model
|
|
228
|
+
const routing = routeTask(
|
|
229
|
+
{ type: subtask.type, complexity: "medium", estimatedTokens: Math.ceil(subtask.task.length / 4) + 800, parallelizable: false },
|
|
230
|
+
null,
|
|
231
|
+
{ costPreference }
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
opts.onSubtaskStart?.(subtask);
|
|
235
|
+
|
|
236
|
+
let attempt = 0;
|
|
237
|
+
while (attempt <= MAX_RETRIES) {
|
|
238
|
+
try {
|
|
239
|
+
// Build context from dependencies
|
|
240
|
+
const depContext = subtask.dependencies
|
|
241
|
+
.map(depId => completedResults[depId])
|
|
242
|
+
.filter(Boolean)
|
|
243
|
+
.map((r, i) => `### Dependency ${i + 1} result:\n${r}`)
|
|
244
|
+
.join("\n\n");
|
|
245
|
+
|
|
246
|
+
const fullTask = depContext
|
|
247
|
+
? `${subtask.task}\n\n---\nContext from previous steps:\n${depContext}`
|
|
248
|
+
: subtask.task;
|
|
249
|
+
|
|
250
|
+
// Use sub-agent manager if available, else direct provider call
|
|
251
|
+
let result;
|
|
252
|
+
if (engine.subagents) {
|
|
253
|
+
const agent = await engine.subagents.spawn({
|
|
254
|
+
task: fullTask,
|
|
255
|
+
label: `decomposed-${subtask.id}`,
|
|
256
|
+
model: routing.model,
|
|
257
|
+
timeout: 120, // seconds
|
|
258
|
+
workstream: engine._activeWorkstream,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Wait for completion
|
|
262
|
+
result = await engine.subagents.waitFor(agent.id, 120_000);
|
|
263
|
+
result = result.result ?? result.error ?? "(no result)";
|
|
264
|
+
} else {
|
|
265
|
+
// Direct provider call
|
|
266
|
+
const messages = [
|
|
267
|
+
{ role: "system", content: `You are a focused worker agent. Complete only this specific subtask. Be concise.` },
|
|
268
|
+
{ role: "user", content: fullTask },
|
|
269
|
+
];
|
|
270
|
+
const response = await engine.providers.chat(messages, [], { model: routing.model });
|
|
271
|
+
result = response.type === "text" ? response.text : JSON.stringify(response);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
completedResults[subtask.id] = result;
|
|
275
|
+
opts.onSubtaskComplete?.(subtask, result);
|
|
276
|
+
return { id: subtask.id, result, routing, success: true };
|
|
277
|
+
} catch (err) {
|
|
278
|
+
attempt++;
|
|
279
|
+
if (attempt > MAX_RETRIES) {
|
|
280
|
+
const error = { id: subtask.id, error: err.message, subtask };
|
|
281
|
+
errors.push(error);
|
|
282
|
+
opts.onSubtaskFail?.(subtask, err);
|
|
283
|
+
// Non-critical: continue with empty result
|
|
284
|
+
completedResults[subtask.id] = `[subtask ${subtask.id} failed: ${err.message}]`;
|
|
285
|
+
return { id: subtask.id, result: null, error: err.message, success: false };
|
|
286
|
+
}
|
|
287
|
+
// Retry
|
|
288
|
+
await new Promise(r => setTimeout(r, 1000 * attempt));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Wait for all in group before proceeding to next group
|
|
294
|
+
await Promise.all(groupPromises);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Collect results in subtask order
|
|
298
|
+
const orderedResults = plan.subtasks.map(st => ({
|
|
299
|
+
id: st.id,
|
|
300
|
+
task: st.task,
|
|
301
|
+
type: st.type,
|
|
302
|
+
result: completedResults[st.id] ?? null,
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
// Synthesize
|
|
306
|
+
let synthesized;
|
|
307
|
+
try {
|
|
308
|
+
synthesized = await synthesizeResults(orderedResults, engine);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
// Fallback: concatenate results
|
|
311
|
+
synthesized = orderedResults
|
|
312
|
+
.filter(r => r.result)
|
|
313
|
+
.map(r => `**${r.type.toUpperCase()}**: ${r.result}`)
|
|
314
|
+
.join("\n\n---\n\n");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
results: orderedResults,
|
|
319
|
+
synthesized,
|
|
320
|
+
errors,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Synthesize results ────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Synthesize multiple subtask results into a coherent response.
|
|
328
|
+
*
|
|
329
|
+
* @param {Array<{id, task, type, result}>} subtaskResults
|
|
330
|
+
* @param {object} [engine] - WispyEngine instance (for LLM synthesis)
|
|
331
|
+
* @returns {Promise<string>}
|
|
332
|
+
*/
|
|
333
|
+
export async function synthesizeResults(subtaskResults, engine) {
|
|
334
|
+
const validResults = subtaskResults.filter(r => r.result && !r.result.startsWith("[subtask"));
|
|
335
|
+
|
|
336
|
+
if (validResults.length === 0) {
|
|
337
|
+
return "All subtasks failed to produce results.";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (validResults.length === 1) {
|
|
341
|
+
return validResults[0].result;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Build synthesis prompt
|
|
345
|
+
const parts = validResults.map((r, i) =>
|
|
346
|
+
`### Subtask ${i + 1} (${r.type}): ${r.task.slice(0, 100)}\n${r.result.slice(0, 2000)}`
|
|
347
|
+
).join("\n\n---\n\n");
|
|
348
|
+
|
|
349
|
+
// If no engine, concatenate
|
|
350
|
+
if (!engine) {
|
|
351
|
+
return `## Combined Results\n\n${parts}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const { model } = getCheapDecomposerModel();
|
|
355
|
+
|
|
356
|
+
const messages = [
|
|
357
|
+
{
|
|
358
|
+
role: "system",
|
|
359
|
+
content: `You are a synthesis agent. Merge multiple subtask outputs into a single, coherent, well-structured response.
|
|
360
|
+
Remove redundancy. Resolve conflicts by noting them. Maintain all important information.`,
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
role: "user",
|
|
364
|
+
content: `Synthesize these ${validResults.length} subtask results into one coherent response:\n\n${parts}`,
|
|
365
|
+
},
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const result = await engine.providers.chat(messages, [], { model });
|
|
370
|
+
return result.type === "text" ? result.text : JSON.stringify(result);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
// Fallback
|
|
373
|
+
return `## Synthesized Results\n\n${parts}`;
|
|
374
|
+
}
|
|
375
|
+
}
|
package/core/task-router.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export const MODEL_CAPABILITIES = {
|
|
|
42
42
|
|
|
43
43
|
// Claude family
|
|
44
44
|
"claude-opus-4-20250514": {
|
|
45
|
-
strengths: ["architecture", "reasoning", "writing", "analysis"],
|
|
45
|
+
strengths: ["architecture", "reasoning", "writing", "analysis", "design"],
|
|
46
46
|
speed: "slow",
|
|
47
47
|
cost: "very-high",
|
|
48
48
|
contextWindow: 200000,
|
|
@@ -327,7 +327,7 @@ export function routeTask(task, availableModels, opts = {}) {
|
|
|
327
327
|
score -= costScore(model) * 2;
|
|
328
328
|
score -= speedScore(model);
|
|
329
329
|
} else if (costPreference === "maximize-quality") {
|
|
330
|
-
score +=
|
|
330
|
+
score += costScore(model) * 2; // prefer expensive (high quality)
|
|
331
331
|
score -= speedScore(model) * 0.5;
|
|
332
332
|
} else {
|
|
333
333
|
// balanced: for complex tasks lean toward quality, simple tasks lean toward speed+cost
|
package/core/tools.mjs
CHANGED
|
@@ -275,6 +275,57 @@ export class ToolRegistry {
|
|
|
275
275
|
required: ["id", "message"],
|
|
276
276
|
},
|
|
277
277
|
},
|
|
278
|
+
// ── Browser tools ────────────────────────────────────────────────────────
|
|
279
|
+
{
|
|
280
|
+
name: "browser_status",
|
|
281
|
+
description: "Check browser bridge health and current session status",
|
|
282
|
+
parameters: { type: "object", properties: {} },
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: "browser_tabs",
|
|
286
|
+
description: "List all open browser tabs",
|
|
287
|
+
parameters: {
|
|
288
|
+
type: "object",
|
|
289
|
+
properties: {
|
|
290
|
+
browser: { type: "string", enum: ["safari", "chrome"] },
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
name: "browser_navigate",
|
|
296
|
+
description: "Navigate the active browser tab to a URL",
|
|
297
|
+
parameters: {
|
|
298
|
+
type: "object",
|
|
299
|
+
properties: { url: { type: "string" } },
|
|
300
|
+
required: ["url"],
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "browser_screenshot",
|
|
305
|
+
description: "Take a screenshot of the active browser tab",
|
|
306
|
+
parameters: { type: "object", properties: {} },
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "browser_front_tab",
|
|
310
|
+
description: "Get info about the currently active browser tab (URL, title)",
|
|
311
|
+
parameters: { type: "object", properties: {} },
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: "browser_activate",
|
|
315
|
+
description: "Bring the browser tab to front / focus it",
|
|
316
|
+
parameters: { type: "object", properties: {} },
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: "browser_attach",
|
|
320
|
+
description: "Attach to a browser for control. Auto-selects the best available browser if no args given.",
|
|
321
|
+
parameters: {
|
|
322
|
+
type: "object",
|
|
323
|
+
properties: {
|
|
324
|
+
browser: { type: "string" },
|
|
325
|
+
mode: { type: "string" },
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
278
329
|
];
|
|
279
330
|
|
|
280
331
|
for (const def of builtins) {
|
|
@@ -594,6 +645,14 @@ export class ToolRegistry {
|
|
|
594
645
|
case "get_subagent_result":
|
|
595
646
|
case "kill_subagent":
|
|
596
647
|
case "steer_subagent":
|
|
648
|
+
// Browser tools — handled at engine level
|
|
649
|
+
case "browser_status":
|
|
650
|
+
case "browser_tabs":
|
|
651
|
+
case "browser_navigate":
|
|
652
|
+
case "browser_screenshot":
|
|
653
|
+
case "browser_front_tab":
|
|
654
|
+
case "browser_activate":
|
|
655
|
+
case "browser_attach":
|
|
597
656
|
return { success: false, error: `Tool "${name}" requires engine context. Call via WispyEngine.processMessage().` };
|
|
598
657
|
|
|
599
658
|
default:
|