wisp-agents 0.6.0 → 0.8.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/CHANGELOG.md +52 -1
- package/README.md +90 -1
- package/bin/wisp.js +52 -5
- package/package.json +2 -2
- package/server/adapters/claude.js +4 -0
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +4 -0
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +473 -21
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +7 -1
- package/server/openapi.js +168 -4
- package/server/preferences.js +296 -0
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +279 -0
- package/server/render.js +18 -0
- package/server/runner.js +153 -13
- package/server/store.js +9 -1
- package/server/subagents.js +85 -0
- package/server/triggers.js +268 -0
- package/server/update.js +136 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +3 -2
- package/web/css/app.css +637 -14
- package/web/index.html +15 -3
- package/web/js/app.js +2060 -87
- package/web/js/blocks.js +396 -22
package/server/runner.js
CHANGED
|
@@ -9,13 +9,27 @@ import path from 'node:path';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { adapters } from './adapters/index.js';
|
|
11
11
|
import { systemPrompt, parsePresentation, heroOf } from './protocol.js';
|
|
12
|
+
import { captureCheckpoint, freshCheckpoint, readGitFile } from './checkpoint.js';
|
|
13
|
+
import { teamNote, copies, SUBAGENT_ASK } from './subagents.js';
|
|
12
14
|
|
|
13
15
|
const MAX_ACTIVITY = 400;
|
|
14
16
|
const LINGER_MS = 3000; // how long a CLI may keep running after it reported its result
|
|
15
17
|
const ASK_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'mcp-ask.js');
|
|
18
|
+
// The delegate tool lives on the same injected server, so harnesses name it like ask_user.
|
|
19
|
+
const delegateTool = (askTool) => askTool && askTool.replace(/ask_user$/, 'delegate');
|
|
16
20
|
|
|
17
21
|
export const newId = () => randomUUID().replace(/-/g, '').slice(0, 12);
|
|
18
22
|
|
|
23
|
+
// Where the agent actually works: its own worktree when it has one, else the project folder.
|
|
24
|
+
export const workdir = (s) => s.worktree?.cwd || s.cwd;
|
|
25
|
+
|
|
26
|
+
// Worktree sessions get told where they are, so they don't go looking for the main checkout.
|
|
27
|
+
function workspaceNote(s) {
|
|
28
|
+
const wt = s.worktree;
|
|
29
|
+
if (!wt) return '';
|
|
30
|
+
return `\n\n## Workspace\n\nYou are in an isolated git worktree (${wt.path}) on branch \`${wt.branch}\`, created from \`${wt.baseRef}\` for this task only. Other agents may be working in the user's main checkout, so make every change here. Dependencies and build output (node_modules, virtualenvs, dist/) are not shared with the main checkout: install them if a command needs them. Leave your changes uncommitted unless the user asks you to commit; the user ships them from wisp (commit, push, pull request).`;
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
function autoTitle(prompt) {
|
|
20
34
|
const line = prompt.trim().split('\n')[0].replace(/\s+/g, ' ');
|
|
21
35
|
return line.length > 64 ? `${line.slice(0, 63)}…` : line;
|
|
@@ -26,6 +40,9 @@ export function summarize(s, store) {
|
|
|
26
40
|
const last = s.turns.at(-1);
|
|
27
41
|
const hero = heroOf(last?.presentation);
|
|
28
42
|
const lastAct = last?.activity?.findLast?.((a) => a.kind !== 'text');
|
|
43
|
+
// A lead blocked on its subagents says who it's waiting for.
|
|
44
|
+
const busy = last?.status === 'running' ? (last.delegations || []).filter((d) => store?.get(d.sessionId)?.turns.find((t) => t.id === d.turnId)?.status === 'running') : [];
|
|
45
|
+
const waitingOn = busy.length ? `Waiting on ${[...new Set(busy.map((d) => d.name))].join(', ')}` : '';
|
|
29
46
|
return {
|
|
30
47
|
id: s.id,
|
|
31
48
|
title: s.title,
|
|
@@ -46,9 +63,21 @@ export function summarize(s, store) {
|
|
|
46
63
|
queued: s.queue?.length || 0,
|
|
47
64
|
status: last?.status === 'running' ? 'running' : hero?.status || (last?.status === 'error' ? 'blocked' : 'info'),
|
|
48
65
|
preview: hero?.summary || '',
|
|
49
|
-
live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? lastAct?.label || 'Starting up' : '',
|
|
66
|
+
live: s.ask ? `Asks: ${s.ask.question}` : last?.status === 'running' ? waitingOn || lastAct?.label || 'Starting up' : '',
|
|
50
67
|
asks: !!s.ask || !!last?.presentation?.blocks?.some((b) => b.type === 'question'),
|
|
51
68
|
question: s.ask?.question || '',
|
|
69
|
+
branch: s.worktree?.branch || '',
|
|
70
|
+
pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: s.pr.state, draft: s.pr.draft, review: s.pr.review, checks: s.pr.checks } : null,
|
|
71
|
+
groupId: s.groupId || null,
|
|
72
|
+
origin: s.origin || null, // { kind: 'recipe' | 'trigger', id, name }
|
|
73
|
+
// The roster, for the stacked logos on the card: used = delegated to at least once, busy = working now.
|
|
74
|
+
team: (s.subagents || []).flatMap((m) =>
|
|
75
|
+
copies(m).map((name) => {
|
|
76
|
+
const child = store?.get(s.children?.[name]);
|
|
77
|
+
return { name, agent: m.agent, model: m.model, effort: m.effort, used: !!child, busy: child?.state === 'running' };
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
...(s.parentId && { parentId: s.parentId, member: s.member }),
|
|
52
81
|
};
|
|
53
82
|
}
|
|
54
83
|
|
|
@@ -62,6 +91,7 @@ export class Runner extends EventEmitter {
|
|
|
62
91
|
this.stopping = new Set();
|
|
63
92
|
this.interrupting = new Set();
|
|
64
93
|
this.answers = new Map(); // askId -> answer, until the MCP server picks it up
|
|
94
|
+
this.promptExtra = () => ''; // (session) => text appended to the system prompt (learned preferences)
|
|
65
95
|
|
|
66
96
|
// Anything that was mid-flight when wisp last died is now orphaned.
|
|
67
97
|
for (const s of store.all()) {
|
|
@@ -75,11 +105,12 @@ export class Runner extends EventEmitter {
|
|
|
75
105
|
}
|
|
76
106
|
}
|
|
77
107
|
|
|
78
|
-
|
|
108
|
+
// subagents: the team a lead may delegate to. parentId/member: this session is one of them.
|
|
109
|
+
create({ id = newId(), prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [], worktree = null, groupId = null, origin = null, subagents = [], parentId = null, member = '' }) {
|
|
79
110
|
if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
|
|
80
111
|
const now = Date.now();
|
|
81
112
|
const session = {
|
|
82
|
-
id
|
|
113
|
+
id,
|
|
83
114
|
title: autoTitle(prompt),
|
|
84
115
|
titleAuto: true,
|
|
85
116
|
agent,
|
|
@@ -94,9 +125,14 @@ export class Runner extends EventEmitter {
|
|
|
94
125
|
agentSessionId: null,
|
|
95
126
|
queue: [],
|
|
96
127
|
turns: [],
|
|
128
|
+
...(worktree && { worktree }),
|
|
129
|
+
...(groupId && { groupId }),
|
|
130
|
+
...(origin && { origin }),
|
|
131
|
+
...(subagents.length && { subagents, children: {} }),
|
|
132
|
+
...(parentId && { parentId, member }),
|
|
97
133
|
};
|
|
98
134
|
this.store.put(session);
|
|
99
|
-
this.store.touchProject(cwd);
|
|
135
|
+
if (!parentId) this.store.touchProject(cwd); // a subagent's cwd may be the lead's worktree
|
|
100
136
|
this.#startTurn(session, prompt, attachments);
|
|
101
137
|
return session;
|
|
102
138
|
}
|
|
@@ -183,9 +219,75 @@ export class Runner extends EventEmitter {
|
|
|
183
219
|
return open ? { pending: true } : { cancelled: true };
|
|
184
220
|
}
|
|
185
221
|
|
|
222
|
+
// delegate: the lead hands tasks to members of its team. Each member is one child session,
|
|
223
|
+
// created on first use and resumed after that. Returns one record (or { error }) per task.
|
|
224
|
+
delegate(lead, tasks) {
|
|
225
|
+
const turn = lead.turns.at(-1);
|
|
226
|
+
if (turn?.status !== 'running') throw Object.assign(new Error('The lead agent is not running.'), { status: 409 });
|
|
227
|
+
const team = lead.subagents || [];
|
|
228
|
+
if (!team.length) throw Object.assign(new Error('This session has no subagents. The user can add them when launching.'), { status: 400 });
|
|
229
|
+
const out = [];
|
|
230
|
+
const claimed = new Set(); // copies handed a task in this call
|
|
231
|
+
const busy = (name) => claimed.has(name) || this.live.has(lead.children?.[name]);
|
|
232
|
+
for (const task of Array.isArray(tasks) ? tasks.slice(0, 12) : []) {
|
|
233
|
+
const want = String(task?.subagent || '').trim();
|
|
234
|
+
const m = team.find((x) => x.name === want || copies(x).includes(want));
|
|
235
|
+
const prompt = String(task?.prompt || '').trim();
|
|
236
|
+
if (!m) {
|
|
237
|
+
out.push({ subagent: want, error: `No subagent named "${want}". The team: ${team.map((x) => x.name).join(', ')}.` });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!prompt) {
|
|
241
|
+
out.push({ subagent: want, error: 'The task prompt is empty.' });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// A member name goes to its first free copy; a copy name (codex#2) to that copy.
|
|
245
|
+
const slot = (want === m.name ? copies(m) : [want]).find((n) => !busy(n));
|
|
246
|
+
if (!slot) {
|
|
247
|
+
out.push({ subagent: want, error: `${want} is still working${m.count > 1 && want === m.name ? ` (all ${m.count} copies)` : ''} on earlier tasks. Wait with subagent_results, then delegate again.` });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
claimed.add(slot);
|
|
251
|
+
let child = this.store.get(lead.children?.[slot]);
|
|
252
|
+
if (child) {
|
|
253
|
+
child.cwd = workdir(lead); // follows the lead if its worktree moved
|
|
254
|
+
this.#startTurn(child, prompt);
|
|
255
|
+
} else {
|
|
256
|
+
child = this.create({ prompt, cwd: workdir(lead), agent: m.agent, mode: lead.mode, model: m.model, effort: m.effort, parentId: lead.id, member: slot });
|
|
257
|
+
(lead.children ||= {})[slot] = child.id;
|
|
258
|
+
}
|
|
259
|
+
const d = { id: newId(), name: slot, member: m.name, agent: m.agent, model: m.model, effort: m.effort, sessionId: child.id, turnId: child.turns.at(-1).id, prompt, startedAt: Date.now() };
|
|
260
|
+
(turn.delegations ||= []).push(d);
|
|
261
|
+
out.push(d);
|
|
262
|
+
}
|
|
263
|
+
const sent = out.filter((d) => !d.error);
|
|
264
|
+
if (sent.length) this.#onEvent(lead, turn, { type: 'tool', kind: 'agent', label: `Delegated to ${[...new Set(sent.map((d) => d.name))].join(', ')}` });
|
|
265
|
+
this.#touch(lead, { turn: turn.id });
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Delegations by id (any turn), or the current turn's when ids is empty, with their child sessions.
|
|
270
|
+
delegations(lead, ids = []) {
|
|
271
|
+
const all = lead.turns.flatMap((t) => t.delegations || []);
|
|
272
|
+
const list = ids.length ? all.filter((d) => ids.includes(d.id)) : lead.turns.at(-1)?.delegations || [];
|
|
273
|
+
return list.map((d) => ({ d, child: this.store.get(d.sessionId) }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A child's state changed: the lead's card shows it, so the lead's turn re-renders too.
|
|
277
|
+
#touchLead(child) {
|
|
278
|
+
const lead = child.parentId && this.store.get(child.parentId);
|
|
279
|
+
if (!lead) return;
|
|
280
|
+
const turn = lead.turns.findLast((t) => t.delegations?.some((d) => d.sessionId === child.id)) || lead.turns.at(-1);
|
|
281
|
+
this.#touch(lead, { turn: turn?.id });
|
|
282
|
+
}
|
|
283
|
+
|
|
186
284
|
stop(session) {
|
|
187
285
|
session.queue = [];
|
|
188
286
|
delete session.ask;
|
|
287
|
+
for (const cid of Object.values(session.children || {})) {
|
|
288
|
+
const child = this.store.get(cid);
|
|
289
|
+
if (child) this.stop(child);
|
|
290
|
+
}
|
|
189
291
|
if (!this.live.has(session.id)) return;
|
|
190
292
|
this.stopping.add(session.id);
|
|
191
293
|
this.live.get(session.id).stop();
|
|
@@ -200,8 +302,16 @@ export class Runner extends EventEmitter {
|
|
|
200
302
|
}
|
|
201
303
|
|
|
202
304
|
#startTurn(session, prompt, attachments = []) {
|
|
305
|
+
const id = newId();
|
|
306
|
+
// A subagent's edits land in the lead's turn, whose checkpoint already covers them.
|
|
307
|
+
const checkpoint =
|
|
308
|
+
session.agent === 'wisp' || session.parentId
|
|
309
|
+
? null
|
|
310
|
+
: !session.turns.length && session.worktree?.head
|
|
311
|
+
? freshCheckpoint(session.worktree)
|
|
312
|
+
: captureCheckpoint(workdir(session), session.id, id);
|
|
203
313
|
const turn = {
|
|
204
|
-
id
|
|
314
|
+
id,
|
|
205
315
|
prompt,
|
|
206
316
|
agent: session.agent,
|
|
207
317
|
model: session.model,
|
|
@@ -210,6 +320,7 @@ export class Runner extends EventEmitter {
|
|
|
210
320
|
startedAt: Date.now(),
|
|
211
321
|
endedAt: null,
|
|
212
322
|
status: 'running',
|
|
323
|
+
checkpoint,
|
|
213
324
|
activity: [],
|
|
214
325
|
changes: [],
|
|
215
326
|
presentation: null,
|
|
@@ -220,28 +331,40 @@ export class Runner extends EventEmitter {
|
|
|
220
331
|
session.unread = false;
|
|
221
332
|
delete session.archived;
|
|
222
333
|
this.#touch(session, { turn: turn.id });
|
|
334
|
+
this.#touchLead(session);
|
|
223
335
|
this.emit('started', { session, turn });
|
|
224
336
|
|
|
225
337
|
let released = false;
|
|
226
338
|
let linger;
|
|
227
339
|
const adapter = adapters[session.agent];
|
|
340
|
+
// Leads get the delegate tool next to ask_user; subagents get neither (nobody to ask).
|
|
341
|
+
const team = adapter.askTool !== null ? session.subagents || [] : [];
|
|
342
|
+
const prompt0 = session.parentId
|
|
343
|
+
? systemPrompt({ ask: SUBAGENT_ASK })
|
|
344
|
+
: systemPrompt({ askTool: adapter.askTool }) + teamNote(team, adapters, delegateTool(adapter.askTool ?? 'mcp__wisp__ask_user'));
|
|
228
345
|
const handle = adapter.run({
|
|
229
346
|
prompt,
|
|
230
347
|
attachments,
|
|
231
|
-
cwd: session
|
|
348
|
+
cwd: workdir(session),
|
|
232
349
|
resumeId: session.agentSessionId,
|
|
233
350
|
mode: session.mode,
|
|
234
351
|
model: session.model,
|
|
235
352
|
effort: session.effort || '',
|
|
236
|
-
systemPrompt:
|
|
237
|
-
mcpServers: this.bridge && adapter.askTool !== null
|
|
353
|
+
systemPrompt: prompt0 + workspaceNote(session) + this.promptExtra(session),
|
|
354
|
+
mcpServers: this.bridge && adapter.askTool !== null && !session.parentId
|
|
238
355
|
? {
|
|
239
356
|
wisp: {
|
|
240
357
|
command: process.execPath,
|
|
241
358
|
args: [ASK_SERVER],
|
|
242
359
|
// Inside the desktop app execPath is the Electron binary; without
|
|
243
360
|
// ELECTRON_RUN_AS_NODE it boots a second app (extra Dock icon) per turn.
|
|
244
|
-
env: {
|
|
361
|
+
env: {
|
|
362
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
363
|
+
WISP_URL: this.bridge.url,
|
|
364
|
+
WISP_TOKEN: this.bridge.token,
|
|
365
|
+
WISP_SESSION: session.id,
|
|
366
|
+
...(team.length && { WISP_TEAM: JSON.stringify(team.map((m) => ({ name: m.name, agent: adapters[m.agent]?.name || m.agent, model: m.model, effort: m.effort, use: m.use, count: m.count || 1, copies: copies(m) }))) }),
|
|
367
|
+
},
|
|
245
368
|
},
|
|
246
369
|
}
|
|
247
370
|
: {},
|
|
@@ -293,10 +416,24 @@ export class Runner extends EventEmitter {
|
|
|
293
416
|
}
|
|
294
417
|
case 'change': {
|
|
295
418
|
let entry = turn.changes.find((c) => c.file === ev.file);
|
|
296
|
-
if (!entry)
|
|
419
|
+
if (!entry) {
|
|
420
|
+
let beforeFile = ev.beforeFile;
|
|
421
|
+
if (turn.checkpoint?.commit && !path.isAbsolute(ev.file)) {
|
|
422
|
+
const fromGit = readGitFile(workdir(session), turn.checkpoint.commit, ev.file);
|
|
423
|
+
if (fromGit !== null) beforeFile = fromGit;
|
|
424
|
+
else if (!(turn.checkpoint.untracked || []).includes(ev.file)) beforeFile = null;
|
|
425
|
+
}
|
|
426
|
+
turn.changes.push((entry = { file: ev.file, op: ev.op, ...(beforeFile !== undefined ? { beforeFile } : {}), edits: [] }));
|
|
427
|
+
}
|
|
297
428
|
if (ev.op === 'create' && entry.edits.length === 0) entry.op = 'create';
|
|
429
|
+
delete entry.reverted;
|
|
298
430
|
entry.edits.push(...ev.edits);
|
|
431
|
+
if (ev.by && !entry.by?.includes(ev.by)) (entry.by ||= []).push(ev.by);
|
|
299
432
|
this.store.save(session);
|
|
433
|
+
// A subagent's edits are the lead's too: they show up in its files, diffs and Ship it.
|
|
434
|
+
const lead = session.parentId && this.store.get(session.parentId);
|
|
435
|
+
const leadTurn = lead?.turns.at(-1);
|
|
436
|
+
if (leadTurn?.status === 'running') this.#onEvent(lead, leadTurn, { ...ev, by: session.member });
|
|
300
437
|
return;
|
|
301
438
|
}
|
|
302
439
|
case 'result':
|
|
@@ -336,15 +473,18 @@ export class Runner extends EventEmitter {
|
|
|
336
473
|
turn.presentation = parsePresentation(ev.text);
|
|
337
474
|
this.store.keepImages(session, turn);
|
|
338
475
|
const hero = heroOf(turn.presentation);
|
|
339
|
-
|
|
476
|
+
// Compare runs keep the prompt's title: they're one task, and one run's headline shouldn't name it.
|
|
477
|
+
if (hero?.title && session.titleAuto && session.turns.length === 1 && !session.groupId) session.title = hero.title;
|
|
340
478
|
}
|
|
341
479
|
session.state = 'review';
|
|
342
480
|
session.unread = !interrupted;
|
|
343
481
|
this.store.save(session, { now: true });
|
|
344
482
|
this.#touch(session, { turn: turn.id });
|
|
345
483
|
this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
|
|
346
|
-
|
|
347
|
-
|
|
484
|
+
this.#touchLead(session);
|
|
485
|
+
// The follow-up starts right away; don't notify for the run it replaced. A subagent reports
|
|
486
|
+
// to its lead, not to the user.
|
|
487
|
+
if (!interrupted && !session.parentId) this.emit('finished', { session: summarize(session, this.store) });
|
|
348
488
|
}
|
|
349
489
|
|
|
350
490
|
#touch(session, { turn, quiet } = {}) {
|
package/server/store.js
CHANGED
|
@@ -9,6 +9,10 @@ import { parsePresentation } from './protocol.js';
|
|
|
9
9
|
const DEFAULT_CONFIG = {
|
|
10
10
|
projects: [], // [{ id, name, path, color, addedAt }]
|
|
11
11
|
defaults: { agent: 'claude', mode: 'edits', model: '', effort: '' },
|
|
12
|
+
recipes: [], // saved prompts with {params}: server/recipes.js
|
|
13
|
+
triggers: [], // schedules and GitHub watchers: server/triggers.js
|
|
14
|
+
teams: [], // saved subagent presets: server/subagents.js
|
|
15
|
+
remote: {}, // phone channels (ntfy, Telegram): server/remote.js
|
|
12
16
|
};
|
|
13
17
|
|
|
14
18
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
@@ -47,6 +51,10 @@ export class Store {
|
|
|
47
51
|
const raw = JSON.parse(fs.readFileSync(path.join(this.home, 'config.json'), 'utf8'));
|
|
48
52
|
const cfg = { ...structuredClone(DEFAULT_CONFIG), ...raw, defaults: { ...DEFAULT_CONFIG.defaults, ...raw.defaults } };
|
|
49
53
|
cfg.projects ||= [];
|
|
54
|
+
cfg.recipes ||= [];
|
|
55
|
+
cfg.triggers ||= [];
|
|
56
|
+
cfg.teams ||= [];
|
|
57
|
+
cfg.remote ||= {};
|
|
50
58
|
return cfg;
|
|
51
59
|
} catch {
|
|
52
60
|
return structuredClone(DEFAULT_CONFIG);
|
|
@@ -130,7 +138,7 @@ export class Store {
|
|
|
130
138
|
(turn.presentation?.blocks || []).forEach((b, i) => {
|
|
131
139
|
const rel = b?.type === 'image' && (b.path || b.src);
|
|
132
140
|
if (typeof rel !== 'string' || !rel || /^https?:/.test(rel)) return;
|
|
133
|
-
const src = path.resolve(session.cwd || this.home, rel);
|
|
141
|
+
const src = path.resolve(session.worktree?.cwd || session.cwd || this.home, rel);
|
|
134
142
|
const ext = path.extname(src).toLowerCase();
|
|
135
143
|
if (!IMAGE_EXT.has(ext) || src.startsWith(this.shots + path.sep)) return;
|
|
136
144
|
try {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Subagents: a team the user picks at launch (any harness, model and thinking level each) that
|
|
2
|
+
// the lead agent can hand work to through wisp's injected `delegate` tool. Each member is a
|
|
3
|
+
// hidden child session working in the lead's folder; delegating to it again resumes it.
|
|
4
|
+
|
|
5
|
+
import { blocksToMarkdown } from './render.js';
|
|
6
|
+
|
|
7
|
+
export const MAX_SUBAGENTS = 6; // rows in the team
|
|
8
|
+
export const MAX_COPIES = 5; // parallel copies of one row
|
|
9
|
+
export const MAX_INSTANCES = 12; // copies across the whole team
|
|
10
|
+
const EFFORT = /^[\w-]{1,20}$/;
|
|
11
|
+
const MAX_USE = 300;
|
|
12
|
+
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
|
|
13
|
+
|
|
14
|
+
// [{ name?, agent, model?, effort?, use?, count? }] → a clean roster with unique names (codex, codex-2, …).
|
|
15
|
+
// use: the user's note on when to pick this one ("image work", "research"), shown to the lead.
|
|
16
|
+
// count: how many parallel copies of this member the lead gets (codex#1, codex#2, …).
|
|
17
|
+
export function normalizeTeam(list, adapters) {
|
|
18
|
+
if (!Array.isArray(list)) return [];
|
|
19
|
+
const taken = new Set();
|
|
20
|
+
const team = list.slice(0, MAX_SUBAGENTS).map((m) => {
|
|
21
|
+
const agent = String(m?.agent || '');
|
|
22
|
+
if (!adapters[agent] || agent === 'wisp') throw Object.assign(new Error(`unknown subagent harness: ${agent}`), { status: 400 });
|
|
23
|
+
let base = slug(m.name) || agent;
|
|
24
|
+
let name = base;
|
|
25
|
+
for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
|
|
26
|
+
taken.add(name);
|
|
27
|
+
const use = String(m.use || '').replace(/\s+/g, ' ').trim().slice(0, MAX_USE);
|
|
28
|
+
const count = Math.min(MAX_COPIES, Math.max(1, Math.floor(Number(m.count)) || 1));
|
|
29
|
+
return { name, agent, model: typeof m.model === 'string' ? m.model.slice(0, 120) : '', effort: EFFORT.test(m.effort || '') ? m.effort : '', use, count };
|
|
30
|
+
});
|
|
31
|
+
const total = team.reduce((n, m) => n + m.count, 0);
|
|
32
|
+
if (total > MAX_INSTANCES) throw Object.assign(new Error(`At most ${MAX_INSTANCES} subagents in all (this team has ${total}).`), { status: 400 });
|
|
33
|
+
return team;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The sessions a member runs as: itself, or one per copy (`#` can't appear in a name, so no clashes).
|
|
37
|
+
export const copies = (m) => ((m.count || 1) > 1 ? Array.from({ length: m.count }, (_, i) => `${m.name}#${i + 1}`) : [m.name]);
|
|
38
|
+
|
|
39
|
+
// Saved teams the user can reuse from the launcher: { id, name, members }.
|
|
40
|
+
export function normalizePreset(body, adapters, prev = {}) {
|
|
41
|
+
const name = String(body?.name ?? prev.name ?? '').trim().slice(0, 60);
|
|
42
|
+
if (!name) throw Object.assign(new Error('A preset needs a name.'), { status: 400 });
|
|
43
|
+
const members = body?.members !== undefined ? normalizeTeam(body.members, adapters) : prev.members || [];
|
|
44
|
+
if (!members.length) throw Object.assign(new Error('A preset needs at least one subagent.'), { status: 400 });
|
|
45
|
+
return { id: prev.id || slug(name) + '-' + Math.random().toString(36).slice(2, 6), name, members, updatedAt: Date.now() };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const who = (m, adapters) => [adapters[m.agent]?.name || m.agent, m.model || 'default model', m.effort && `${m.effort} thinking`].filter(Boolean).join(' · ');
|
|
49
|
+
|
|
50
|
+
// Appended to the lead's system prompt. `tool` is how its harness names the delegate tool.
|
|
51
|
+
export function teamNote(team, adapters, tool) {
|
|
52
|
+
if (!team?.length || !tool) return '';
|
|
53
|
+
const rows = team
|
|
54
|
+
.map((m) => `- \`${m.name}\`${m.count > 1 ? ` ×${m.count} (copies ${copies(m).map((c) => `\`${c}\``).join(', ')})` : ''}: ${who(m, adapters)}${m.use ? `. Use it for: ${m.use}` : ''}`)
|
|
55
|
+
.join('\n');
|
|
56
|
+
const multi = team.find((m) => m.count > 1);
|
|
57
|
+
return `\n\n## Subagents
|
|
58
|
+
|
|
59
|
+
The user gave you a team of subagents, each a separate coding agent running on its own harness and model:
|
|
60
|
+
|
|
61
|
+
${rows}
|
|
62
|
+
|
|
63
|
+
Hand them work with the \`delegate\` tool (${tool}): pass one or more tasks, \`{ "subagent": "<name>", "prompt": "…" }\`. Tasks in one call run in parallel and the call returns when all of them finish, with each subagent's report and the files it changed. With \`wait: false\` it returns at once; collect the reports later with \`subagent_results\`. Delegating to the same subagent again continues its conversation, so it remembers earlier tasks.
|
|
64
|
+
|
|
65
|
+
${multi ? `- A subagent with ×N has N identical copies that run at once. Put several tasks for \`${multi.name}\` in one delegate call and each goes to a free copy; name a copy (\`${copies(multi)[1]}\`) to continue that copy's conversation.\n` : ''}- Subagents work in your folder and see each other's edits. Give parallel subagents separate files or concerns.
|
|
66
|
+
- Brief them like a colleague: goal, files, constraints, how to verify. They cannot see your conversation or ask the user.
|
|
67
|
+
- Follow the user's "use it for" notes when you pick a subagent: send each kind of work to the one meant for it.
|
|
68
|
+
- Use them when the user's request calls for it or when the work splits cleanly; you stay responsible for the result. Review what they changed before you report.
|
|
69
|
+
- Your presentation covers the whole task; wisp shows each subagent's report next to it, so don't repeat them in full.`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Replaces the ask_user guidance for a subagent's own system prompt.
|
|
73
|
+
export const SUBAGENT_ASK = `- You are a subagent: a lead agent delegated this task to you and will read your presentation.
|
|
74
|
+
You cannot ask the user anything. Make the call yourself, note the assumption in a "callout"
|
|
75
|
+
block, and finish. Don't commit unless the task says to.`;
|
|
76
|
+
|
|
77
|
+
// What the lead gets back for one delegation.
|
|
78
|
+
export function delegationReport(d, child, adapters) {
|
|
79
|
+
const turn = child?.turns.find((t) => t.id === d.turnId);
|
|
80
|
+
const head = `### ${d.name} (${who(d, adapters)}) — ${turn?.status || 'gone'}`;
|
|
81
|
+
if (!turn) return `${head}\n\nThe subagent session is gone.`;
|
|
82
|
+
if (turn.status === 'running') return `${head}\n\nStill working. Call subagent_results to wait for it.`;
|
|
83
|
+
const files = turn.changes.length ? `\n\nFiles it changed: ${turn.changes.map((c) => `\`${c.file}\``).join(', ')}` : '';
|
|
84
|
+
return `${head}\n\n${blocksToMarkdown(turn.presentation?.blocks || [], turn).trim() || '_No report._'}${files}`;
|
|
85
|
+
}
|