@hncode/hncode 0.2.0 → 0.3.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/LICENSE +674 -219
- package/README.md +1 -0
- package/README_CN.md +2 -0
- package/package.json +1 -1
- package/src/agent-task.js +178 -0
- package/src/agent.js +96 -8
- package/src/colors.js +14 -0
- package/src/config.js +128 -0
- package/src/index.js +7 -2
- package/src/plugin.js +12 -2
- package/src/prompt-presets.js +429 -0
- package/src/subagent-types.js +147 -0
- package/src/swarm-progress.js +293 -0
- package/src/task-output-viewer.js +135 -0
- package/src/tasks-browser.js +279 -0
- package/src/tools/agent-swarm.js +203 -0
- package/src/tools/agent.js +335 -0
- package/src/tools/ask-user-question.js +206 -0
- package/src/tools/bash.js +46 -28
- package/src/tools/edit.js +117 -6
- package/src/tools/glob.js +8 -1
- package/src/tools/index.js +6 -0
- package/src/tools/tasks.js +140 -126
- package/src/tools/todo.js +29 -2
- package/src/tui.js +2057 -292
- package/src/update.js +107 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// TasksBrowser - the full-screen background-task panel (/tasks), mirroring
|
|
2
|
+
// kimi-code's TasksBrowserApp.
|
|
3
|
+
//
|
|
4
|
+
// Layout:
|
|
5
|
+
// TASK BROWSER filter=ALL 2 running 3 total
|
|
6
|
+
// +-- Tasks [all] --+ +-- Detail ----------+
|
|
7
|
+
// | > bash-x run | | Task ID: ... |
|
|
8
|
+
// | agent-y compl | +-- Preview Output -+
|
|
9
|
+
// | | | <live tail> |
|
|
10
|
+
// +------------------+ +-------------------+
|
|
11
|
+
// Up/Down select Enter/O output S stop R refresh Tab filter Q/Esc close
|
|
12
|
+
//
|
|
13
|
+
// This module is pure rendering + key handling. It returns ACTION STRINGS to the
|
|
14
|
+
// caller instead of touching the task store, so all state stays in one place.
|
|
15
|
+
import { C } from './colors.js';
|
|
16
|
+
import { visualWidth } from './term.js';
|
|
17
|
+
import { STATUS_LABEL } from './agent-task.js';
|
|
18
|
+
|
|
19
|
+
const ELLIPSIS = '\u2026';
|
|
20
|
+
const ESC = '\x1b';
|
|
21
|
+
// visWidth() counts ANSI bytes, so strip escapes before measuring: every
|
|
22
|
+
// string here carries colour codes and would otherwise measure far too wide.
|
|
23
|
+
function visWidth(s) { return visualWidth(String(s).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')); }
|
|
24
|
+
const MIN_WIDTH = 48;
|
|
25
|
+
const MIN_HEIGHT = 10;
|
|
26
|
+
const LIST_COL_MIN = 28;
|
|
27
|
+
const LIST_COL_MAX = 44;
|
|
28
|
+
const LIST_COL_RATIO = 0.32;
|
|
29
|
+
|
|
30
|
+
export function statusColorToken(status) {
|
|
31
|
+
if (status === 'running') return C.green;
|
|
32
|
+
if (status === 'completed') return C.gray;
|
|
33
|
+
return C.red;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncateToWidth(s, width) {
|
|
37
|
+
const str = String(s);
|
|
38
|
+
let out = '';
|
|
39
|
+
let n = 0;
|
|
40
|
+
let i = 0;
|
|
41
|
+
while (i < str.length) {
|
|
42
|
+
if (str[i] === ESC) {
|
|
43
|
+
const m = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(str.slice(i));
|
|
44
|
+
if (m) { out += m[0]; i += m[0].length; continue; }
|
|
45
|
+
}
|
|
46
|
+
const cp = str.codePointAt(i);
|
|
47
|
+
const ch = String.fromCodePoint(cp);
|
|
48
|
+
const cw = visWidth(ch);
|
|
49
|
+
if (n + cw > width - 1) {
|
|
50
|
+
// Pad after the ellipsis so the row is EXACTLY `width` columns: a short
|
|
51
|
+
// row breaks the frame's right border alignment.
|
|
52
|
+
const used = n + 1;
|
|
53
|
+
return out + ELLIPSIS + ' '.repeat(Math.max(0, width - used));
|
|
54
|
+
}
|
|
55
|
+
n += cw;
|
|
56
|
+
out += ch;
|
|
57
|
+
i += cp > 0xffff ? 2 : 1;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function padToWidth(s, width) {
|
|
63
|
+
const w = visWidth(s);
|
|
64
|
+
if (w === width) return s;
|
|
65
|
+
if (w > width) return truncateToWidth(s, width);
|
|
66
|
+
return s + ' '.repeat(width - w);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fitExactly(line, width) {
|
|
70
|
+
let s = String(line);
|
|
71
|
+
if (visWidth(s) > width) s = truncateToWidth(s, width);
|
|
72
|
+
return padToWidth(s, width) + C.reset;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function singleLine(t) { return String(t == null ? '' : t).replace(/\s+/g, ' ').trim(); }
|
|
76
|
+
|
|
77
|
+
function relativeTime(ts) {
|
|
78
|
+
if (!ts || !Number.isFinite(ts) || ts <= 0) return '';
|
|
79
|
+
const d = Math.floor(Math.max(0, Date.now() - ts) / 1000);
|
|
80
|
+
if (d < 60) return 'just now';
|
|
81
|
+
const m = Math.floor(d / 60);
|
|
82
|
+
if (m < 60) return m + 'm ago';
|
|
83
|
+
const h = Math.floor(m / 60);
|
|
84
|
+
if (h < 24) return h + 'h ago';
|
|
85
|
+
return Math.floor(h / 24) + 'd ago';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function visibleTasks(tasks, filter) {
|
|
89
|
+
if (filter === 'active') return tasks.filter((t) => t.status === 'running');
|
|
90
|
+
return tasks.slice();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function countByStatus(tasks) {
|
|
94
|
+
const c = { running: 0, completed: 0, failed: 0 };
|
|
95
|
+
for (const t of tasks) {
|
|
96
|
+
if (t.status === 'running') c.running++;
|
|
97
|
+
else if (t.status === 'completed') c.completed++;
|
|
98
|
+
else c.failed++;
|
|
99
|
+
}
|
|
100
|
+
return c;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A framed box: top/bottom rules + side walls, exactly width x height cells.
|
|
104
|
+
function renderFrame(title, content, width, height) {
|
|
105
|
+
if (height < 2 || width < 4) return Array.from({ length: height }, () => ' '.repeat(width));
|
|
106
|
+
const innerW = width - 2;
|
|
107
|
+
const innerH = height - 2;
|
|
108
|
+
const t = String(title || '');
|
|
109
|
+
const topMid = t
|
|
110
|
+
? C.border + '\u2500 ' + C.cyan + C.bold + t + C.reset + C.border + ' '
|
|
111
|
+
+ '\u2500'.repeat(Math.max(0, innerW - visWidth(t) - 3)) + C.reset
|
|
112
|
+
: C.border + '\u2500'.repeat(innerW) + C.reset;
|
|
113
|
+
const lines = [C.border + '\u250c' + topMid + C.border + '\u2510' + C.reset];
|
|
114
|
+
for (let i = 0; i < innerH; i++) {
|
|
115
|
+
lines.push(C.border + '\u2502' + C.reset + fitExactly(content[i] ?? '', innerW) + C.border + '\u2502' + C.reset);
|
|
116
|
+
}
|
|
117
|
+
lines.push(C.border + '\u2514' + '\u2500'.repeat(innerW) + '\u2518' + C.reset);
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function renderTasksBrowser(state, cols, rows) {
|
|
122
|
+
if (cols < MIN_WIDTH || rows < MIN_HEIGHT) {
|
|
123
|
+
const lines = [fitExactly(C.red + 'Terminal too small (need >= ' + MIN_WIDTH + ' x ' + MIN_HEIGHT + ')' + C.reset, cols)];
|
|
124
|
+
for (let i = 1; i < rows; i++) lines.push(' '.repeat(cols));
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
const visible = visibleTasks(state.tasks, state.filter);
|
|
128
|
+
const counts = countByStatus(visible);
|
|
129
|
+
|
|
130
|
+
const segs = [C.cyan + C.bold + ' TASK BROWSER ' + C.reset, C.gray + ' filter=' + (state.filter === 'all' ? 'ALL' : 'ACTIVE') + ' ' + C.reset];
|
|
131
|
+
if (counts.running) segs.push(C.green + ' ' + counts.running + ' running ' + C.reset);
|
|
132
|
+
if (counts.completed) segs.push(C.gray + ' ' + counts.completed + ' completed ' + C.reset);
|
|
133
|
+
if (counts.failed) segs.push(C.red + ' ' + counts.failed + ' interrupted ' + C.reset);
|
|
134
|
+
segs.push(C.gray + ' ' + visible.length + ' total ' + C.reset);
|
|
135
|
+
const header = fitExactly(segs.join(''), cols);
|
|
136
|
+
|
|
137
|
+
const key = (s) => C.cyan + C.bold + s + C.reset;
|
|
138
|
+
const dim = (s) => C.gray + s + C.reset;
|
|
139
|
+
let footer;
|
|
140
|
+
if (state.pendingStop) {
|
|
141
|
+
footer = fitExactly(' ' + C.orange + C.bold + 'Stop' + C.reset + ' ' + C.white + state.pendingStop + C.reset + '? '
|
|
142
|
+
+ key('Y') + ' ' + dim('confirm') + ' ' + key('N') + dim('/') + key('esc') + ' ' + dim('cancel') + ' ', cols);
|
|
143
|
+
} else {
|
|
144
|
+
const parts = [
|
|
145
|
+
' ' + key('\u2191\u2193') + ' ' + dim('select'),
|
|
146
|
+
key('Enter/O') + ' ' + dim('output'),
|
|
147
|
+
key('S') + ' ' + dim('stop'),
|
|
148
|
+
key('R') + ' ' + dim('refresh'),
|
|
149
|
+
key('Tab') + ' ' + dim('filter'),
|
|
150
|
+
key('Q/Esc') + ' ' + dim('close') + ' ',
|
|
151
|
+
];
|
|
152
|
+
const left = parts.join(' ');
|
|
153
|
+
const flash = state.flash ? C.orange + ' ' + state.flash + ' ' + C.reset : '';
|
|
154
|
+
const total = visWidth(left) + visWidth(flash);
|
|
155
|
+
footer = total <= cols ? left + ' '.repeat(cols - total) + flash : fitExactly(left, cols);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const bodyH = rows - 2;
|
|
159
|
+
const listW = Math.max(LIST_COL_MIN, Math.min(LIST_COL_MAX, Math.floor(cols * LIST_COL_RATIO)));
|
|
160
|
+
const rightW = cols - listW;
|
|
161
|
+
|
|
162
|
+
// left: task list
|
|
163
|
+
const listInnerH = Math.max(0, bodyH - 2);
|
|
164
|
+
const listInnerW = listW - 2;
|
|
165
|
+
let listLines;
|
|
166
|
+
if (!visible.length) {
|
|
167
|
+
listLines = [C.gray + (state.filter === 'active' ? 'No active tasks. Tab = show all.' : 'No background tasks in this session.') + C.reset];
|
|
168
|
+
} else {
|
|
169
|
+
listLines = visible.map((t, i) => {
|
|
170
|
+
const selected = i === state.selectedIndex;
|
|
171
|
+
const ptr = selected ? C.cyan + '\u276f ' + C.reset : ' ';
|
|
172
|
+
const idCol = t.kind === 'agent' ? C.green : t.kind === 'question' ? C.orange : C.blue;
|
|
173
|
+
const idText = selected ? idCol + C.bold + t.taskId + C.reset : idCol + t.taskId + C.reset;
|
|
174
|
+
const pad = ' '.repeat(Math.max(0, 20 - visWidth(t.taskId)));
|
|
175
|
+
const badge = statusColorToken(t.status) + (STATUS_LABEL[t.status] || t.status) + C.reset;
|
|
176
|
+
const prefix = ptr + idText + pad + ' ' + badge;
|
|
177
|
+
const budget = Math.max(0, listInnerW - visWidth(prefix) - 1);
|
|
178
|
+
if (budget < 4) return fitExactly(prefix, listInnerW);
|
|
179
|
+
const desc = singleLine(t.description) || singleLine(t.command) || '(no description)';
|
|
180
|
+
return fitExactly(prefix + ' ' + C.white + truncateToWidth(desc, budget) + C.reset, listInnerW);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
while (listLines.length < listInnerH) listLines.push('');
|
|
184
|
+
const listFrame = renderFrame('Tasks [' + state.filter + ']', listLines.slice(0, listInnerH), listW, bodyH);
|
|
185
|
+
|
|
186
|
+
// right: detail + preview
|
|
187
|
+
const detailH = Math.min(Math.max(10, Math.min(Math.floor(bodyH * 0.4), bodyH - 5)), Math.max(3, bodyH - 3));
|
|
188
|
+
const previewH = bodyH - detailH;
|
|
189
|
+
const rightInnerW = rightW - 2;
|
|
190
|
+
const task = visible[state.selectedIndex];
|
|
191
|
+
|
|
192
|
+
const detailLines = [];
|
|
193
|
+
if (!task) {
|
|
194
|
+
detailLines.push(C.gray + 'Select a task from the list.' + C.reset);
|
|
195
|
+
} else {
|
|
196
|
+
const label = (s) => C.gray + s.padEnd(14) + C.reset;
|
|
197
|
+
const val = (s) => C.white + String(s) + C.reset;
|
|
198
|
+
detailLines.push(label('Task ID:') + val(task.taskId));
|
|
199
|
+
detailLines.push(label('Kind:') + val(task.kind));
|
|
200
|
+
detailLines.push(label('Status:') + statusColorToken(task.status) + (STATUS_LABEL[task.status] || task.status) + C.reset);
|
|
201
|
+
detailLines.push(label('Description:') + val(singleLine(task.description) || '-'));
|
|
202
|
+
if (task.kind === 'process') {
|
|
203
|
+
if (task.command && task.command !== task.description) detailLines.push(label('Command:') + val(singleLine(task.command)));
|
|
204
|
+
if (task.pid) detailLines.push(label('Pid:') + C.gray + task.pid + C.reset);
|
|
205
|
+
if (task.exitCode != null) detailLines.push(label('Exit code:') + C.gray + task.exitCode + C.reset);
|
|
206
|
+
}
|
|
207
|
+
if (task.kind === 'agent') {
|
|
208
|
+
if (task.agentId) detailLines.push(label('Agent ID:') + val(task.agentId));
|
|
209
|
+
if (task.subagentType) detailLines.push(label('Agent type:') + val(task.subagentType));
|
|
210
|
+
if (task.model) detailLines.push(label('Model:') + val(task.model));
|
|
211
|
+
if (task.thinkingEffort) detailLines.push(label('Effort:') + val(task.thinkingEffort));
|
|
212
|
+
}
|
|
213
|
+
if (task.kind === 'question') {
|
|
214
|
+
detailLines.push(label('Questions:') + C.gray + task.questionCount + C.reset);
|
|
215
|
+
if (task.toolCallId) detailLines.push(label('Tool call:') + C.gray + task.toolCallId + C.reset);
|
|
216
|
+
}
|
|
217
|
+
const timing = task.status === 'running'
|
|
218
|
+
? 'running ' + relativeTime(task.startedAt)
|
|
219
|
+
: (task.endedAt ? 'finished ' + relativeTime(task.endedAt) : '');
|
|
220
|
+
if (timing) detailLines.push(label('Time:') + C.gray + timing + C.reset);
|
|
221
|
+
if (task.stopReason) detailLines.push(label('Reason:') + C.gray + singleLine(task.stopReason) + C.reset);
|
|
222
|
+
}
|
|
223
|
+
while (detailLines.length < Math.max(0, detailH - 2)) detailLines.push('');
|
|
224
|
+
const detailFrame = renderFrame('Detail', detailLines, rightW, detailH);
|
|
225
|
+
|
|
226
|
+
const previewInnerH = Math.max(0, previewH - 2);
|
|
227
|
+
let previewLines;
|
|
228
|
+
const tail = task ? String(task.output || '') : '';
|
|
229
|
+
if (!task) previewLines = [C.gray + 'No task selected.' + C.reset];
|
|
230
|
+
else if (!tail.length) previewLines = [C.gray + '[no output captured]' + C.reset];
|
|
231
|
+
else previewLines = tail.replace(/\r\n/g, '\n').split('\n').slice(-previewInnerH).map((l) => C.gray + truncateToWidth(l, rightInnerW) + C.reset);
|
|
232
|
+
while (previewLines.length < previewInnerH) previewLines.push('');
|
|
233
|
+
const previewFrame = renderFrame('Preview Output', previewLines, rightW, previewH);
|
|
234
|
+
|
|
235
|
+
const out = [header];
|
|
236
|
+
for (let i = 0; i < bodyH; i++) {
|
|
237
|
+
const right = i < detailH ? (detailFrame[i] ?? ' '.repeat(rightW)) : (previewFrame[i - detailH] ?? ' '.repeat(rightW));
|
|
238
|
+
out.push((listFrame[i] ?? ' '.repeat(listW)) + right);
|
|
239
|
+
}
|
|
240
|
+
out.push(footer);
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Handle one key. Returns an ACTION STRING for the caller (all state stays in
|
|
245
|
+
// the caller): 'close' | 'refresh' | 'toggleFilter' | 'openOutput' |
|
|
246
|
+
// 'requestStop' | 'confirmStop' | 'cancelStop' | 'select' | null
|
|
247
|
+
export function handleTasksBrowserKey(state, t) {
|
|
248
|
+
const visible = visibleTasks(state.tasks, state.filter);
|
|
249
|
+
const ch = t.ch;
|
|
250
|
+
if (state.pendingStop) {
|
|
251
|
+
if (ch === 'y' || ch === 'Y') { state.pendingStop = null; return 'confirmStop'; }
|
|
252
|
+
state.pendingStop = null;
|
|
253
|
+
return 'cancelStop';
|
|
254
|
+
}
|
|
255
|
+
if (t.key === 'escape' || ch === 'q' || ch === 'Q') return 'close';
|
|
256
|
+
if (t.key === 'up' || ch === 'k') {
|
|
257
|
+
if (!visible.length) return null;
|
|
258
|
+
state.selectedIndex = Math.max(0, state.selectedIndex - 1);
|
|
259
|
+
return 'select';
|
|
260
|
+
}
|
|
261
|
+
if (t.key === 'down' || ch === 'j') {
|
|
262
|
+
if (!visible.length) return null;
|
|
263
|
+
state.selectedIndex = Math.min(visible.length - 1, state.selectedIndex + 1);
|
|
264
|
+
return 'select';
|
|
265
|
+
}
|
|
266
|
+
if (t.key === 'home') { state.selectedIndex = 0; return 'select'; }
|
|
267
|
+
if (t.key === 'end') { state.selectedIndex = Math.max(0, visible.length - 1); return 'select'; }
|
|
268
|
+
if (t.key === 'tab') return 'toggleFilter';
|
|
269
|
+
if (ch === 'r' || ch === 'R') return 'refresh';
|
|
270
|
+
if (ch === 's' || ch === 'S') {
|
|
271
|
+
const task = visible[state.selectedIndex];
|
|
272
|
+
if (!task) return null;
|
|
273
|
+
if (task.status !== 'running') return 'stopIgnored';
|
|
274
|
+
state.pendingStop = task.taskId;
|
|
275
|
+
return 'requestStop';
|
|
276
|
+
}
|
|
277
|
+
if (ch === 'o' || ch === 'O' || t.key === 'enter') return 'openOutput';
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// AgentSwarm tool — launch many subagents from ONE prompt template, mirroring
|
|
2
|
+
// kimi-code's AgentSwarm.
|
|
3
|
+
//
|
|
4
|
+
// prompt_template: 'Review {{item}} for regressions.'
|
|
5
|
+
// items: ['src/a.ts', 'src/b.ts'] -> two subagents
|
|
6
|
+
//
|
|
7
|
+
// Enforced rules (same as kimi):
|
|
8
|
+
// * at least 2 items unless resume_agent_ids is given
|
|
9
|
+
// * whenever items are present, prompt_template is required and must contain
|
|
10
|
+
// the {{item}} placeholder
|
|
11
|
+
// * the filled-in prompts must be distinct
|
|
12
|
+
// * at most 128 items
|
|
13
|
+
// * swarm runs are queued, so a large swarm does not spawn 128 processes at once
|
|
14
|
+
|
|
15
|
+
import { spec as agentSpec } from './agent.js';
|
|
16
|
+
import { SUBAGENT_TYPES, DEFAULT_SUBAGENT_TYPE } from '../subagent-types.js';
|
|
17
|
+
import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
|
|
18
|
+
|
|
19
|
+
export const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}';
|
|
20
|
+
export const MAX_AGENT_SWARM_SUBAGENTS = 128;
|
|
21
|
+
// How many subagents run at once. kimi queues launches automatically; we use a
|
|
22
|
+
// small fixed window so a 128-item swarm cannot stampede the machine.
|
|
23
|
+
const SWARM_CONCURRENCY = 4;
|
|
24
|
+
|
|
25
|
+
// Validate and expand the swarm request. Returns { prompts } or { error }.
|
|
26
|
+
export function planSwarm(args) {
|
|
27
|
+
const items = Array.isArray(args.items) ? args.items.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
28
|
+
const template = args.prompt_template == null ? '' : String(args.prompt_template);
|
|
29
|
+
const resumeMap = (args.resume_agent_ids && typeof args.resume_agent_ids === 'object')
|
|
30
|
+
? Object.entries(args.resume_agent_ids).map(([id, p]) => [String(id).trim(), String(p).trim()])
|
|
31
|
+
: [];
|
|
32
|
+
|
|
33
|
+
if (items.length === 0 && resumeMap.length === 0) {
|
|
34
|
+
return { error: 'Error: provide at least 2 items, or a resume_agent_ids map.' };
|
|
35
|
+
}
|
|
36
|
+
if (items.length === 1 && resumeMap.length === 0) {
|
|
37
|
+
return { error: 'Error: at least 2 items are required (a single task should use the Agent tool instead).' };
|
|
38
|
+
}
|
|
39
|
+
if (items.length > MAX_AGENT_SWARM_SUBAGENTS) {
|
|
40
|
+
return { error: `Error: at most ${MAX_AGENT_SWARM_SUBAGENTS} subagents per swarm (got ${items.length}).` };
|
|
41
|
+
}
|
|
42
|
+
if (items.length > 0 && !template.trim()) {
|
|
43
|
+
return { error: 'Error: `prompt_template` is required whenever `items` are present.' };
|
|
44
|
+
}
|
|
45
|
+
if (items.length > 0 && !template.includes(PROMPT_TEMPLATE_PLACEHOLDER)) {
|
|
46
|
+
return { error: `Error: \`prompt_template\` must contain the ${PROMPT_TEMPLATE_PLACEHOLDER} placeholder.` };
|
|
47
|
+
}
|
|
48
|
+
const prompts = items.map((it) => template.split(PROMPT_TEMPLATE_PLACEHOLDER).join(it));
|
|
49
|
+
const uniq = new Set(prompts);
|
|
50
|
+
if (uniq.size !== prompts.length) {
|
|
51
|
+
return { error: 'Error: two items expand to the same prompt — make each item distinct.' };
|
|
52
|
+
}
|
|
53
|
+
return { prompts, resumeMap };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const spec = {
|
|
57
|
+
name: 'AgentSwarm',
|
|
58
|
+
description: `Launch multiple subagents from one prompt template, existing agent resumes, or both.
|
|
59
|
+
|
|
60
|
+
Use AgentSwarm when many subagents should run the same kind of task over different inputs. The placeholder is exactly \`${PROMPT_TEMPLATE_PLACEHOLDER}\`. For example, with \`prompt_template\` set to \`Review ${PROMPT_TEMPLATE_PLACEHOLDER} for likely regressions.\` and \`items\` set to \`["src/a.ts", "src/b.ts"]\`, AgentSwarm launches two new subagents with those two concrete prompts. For a few differently-shaped tasks, make separate \`Agent\` calls in one message instead.
|
|
61
|
+
|
|
62
|
+
Use \`resume_agent_ids\` to continue subagents that already exist from earlier work, such as ones that failed or timed out: map each agent id to the prompt for that resumed subagent (usually \`continue\` if no extra information is needed). You may combine \`resume_agent_ids\` with \`items\` in the same call. Do not duplicate resumed work in \`items\`.
|
|
63
|
+
|
|
64
|
+
Each of these is enforced — a violation is rejected before any subagent starts: provide at least 2 \`items\` unless you pass \`resume_agent_ids\`; whenever \`items\` are present, \`prompt_template\` is required and must contain \`${PROMPT_TEMPLATE_PLACEHOLDER}\`; and the filled-in prompts must be distinct.
|
|
65
|
+
|
|
66
|
+
Use enough subagents to keep the work focused and parallel. AgentSwarm supports up to ${MAX_AGENT_SWARM_SUBAGENTS} subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items.
|
|
67
|
+
|
|
68
|
+
When NOT to use AgentSwarm:
|
|
69
|
+
- Never call this on your own initiative. It requires the same explicit permission as \`Agent\`: the user, or the applicable AGENTS.md / project instructions, must have asked for sub-agents, delegation, or parallel agent work. Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do NOT count as permission.
|
|
70
|
+
- Do not use it for two or three differently-shaped tasks: use separate \`Agent\` calls instead, which can run in parallel in one message.
|
|
71
|
+
- Do not use it to fan out work you could finish yourself in a few steps.
|
|
72
|
+
|
|
73
|
+
If \`AgentSwarm\` is called, that call must be the only tool call in the response.`,
|
|
74
|
+
parameters: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
description: { type: 'string', description: 'Short description for the whole swarm.' },
|
|
78
|
+
subagent_type: { type: 'string', description: `Subagent type used for every new subagent spawned from items; defaults to ${DEFAULT_SUBAGENT_TYPE}.` },
|
|
79
|
+
prompt_template: { type: 'string', description: `Prompt template for each subagent. The ${PROMPT_TEMPLATE_PLACEHOLDER} placeholder is replaced with each item value.` },
|
|
80
|
+
items: {
|
|
81
|
+
type: 'array',
|
|
82
|
+
items: { type: 'string' },
|
|
83
|
+
maxItems: MAX_AGENT_SWARM_SUBAGENTS,
|
|
84
|
+
description: `Values used to fill ${PROMPT_TEMPLATE_PLACEHOLDER}. Each item launches one new subagent.`,
|
|
85
|
+
},
|
|
86
|
+
fork: { type: 'boolean', default: false, description: "Fork the current context for every item-spawned subagent: each starts with a snapshot of this agent's completed conversation history." },
|
|
87
|
+
resume_agent_ids: { type: 'object', description: 'Map of existing subagent agent_id to the prompt used to resume that subagent. Resumed subagents are launched before new item-based subagents.' },
|
|
88
|
+
model: { type: 'string', description: 'Which model to run the item-spawned subagents on, or "primary" for the current model.' },
|
|
89
|
+
},
|
|
90
|
+
required: ['description'],
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async execute(args, ctx) {
|
|
94
|
+
const description = String(args.description || '').trim();
|
|
95
|
+
if (!description) return 'Error: `description` is required.';
|
|
96
|
+
|
|
97
|
+
const plan = planSwarm(args);
|
|
98
|
+
if (plan.error) return plan.error;
|
|
99
|
+
|
|
100
|
+
const type = SUBAGENT_TYPES[args.subagent_type] ? args.subagent_type : DEFAULT_SUBAGENT_TYPE;
|
|
101
|
+
|
|
102
|
+
// Build the work list: resumes first (kimi launches them before new items).
|
|
103
|
+
const jobs = [
|
|
104
|
+
...plan.resumeMap.map(([agentId, prompt]) => ({ kind: 'resume', agentId, prompt })),
|
|
105
|
+
...plan.prompts.map((prompt) => ({ kind: 'new', prompt })),
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
// The swarm runs in the FOREGROUND by default, like Bash and Agent: this call
|
|
109
|
+
// does not return until every subagent has finished, so the turn waits for the
|
|
110
|
+
// result. The task record exists from the start so Ctrl+B can hand the run to
|
|
111
|
+
// the background mid-flight (the TUI's ctrl+b calls `ctx._foreground.detach`).
|
|
112
|
+
const task = createTask(ctx, 'agent', {
|
|
113
|
+
description: `${description} (${jobs.length} subagents)`,
|
|
114
|
+
subagentType: type,
|
|
115
|
+
model: args.model || '',
|
|
116
|
+
detached: false,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Run with a bounded window. Each job appends its verdict to the task output
|
|
120
|
+
// as it finishes, so TaskOutput shows partial progress.
|
|
121
|
+
const results = new Array(jobs.length).fill(null);
|
|
122
|
+
let next = 0;
|
|
123
|
+
const worker = async () => {
|
|
124
|
+
for (;;) {
|
|
125
|
+
const i = next++;
|
|
126
|
+
if (i >= jobs.length) return;
|
|
127
|
+
const job = jobs[i];
|
|
128
|
+
try {
|
|
129
|
+
const subArgs = {
|
|
130
|
+
prompt: job.prompt,
|
|
131
|
+
description: `${description} #${i + 1}`,
|
|
132
|
+
...(job.kind === 'resume' ? { resume: job.agentId } : { subagent_type: type }),
|
|
133
|
+
...(args.model ? { model: args.model } : {}),
|
|
134
|
+
...(args.fork === true && job.kind === 'new' ? { fork: true } : {}),
|
|
135
|
+
};
|
|
136
|
+
const out = await agentSpec.execute(subArgs, { ...ctx, _progressTag: `#${i + 1}` });
|
|
137
|
+
// `agentSpec.execute` returns `agent_id: <id>\n\n<handoff>`. The swarm's
|
|
138
|
+
// caller wants the handoff, and the id is only useful for a later
|
|
139
|
+
// `resume`; keep it on one labelled line rather than letting it lead the
|
|
140
|
+
// block, so the reported text is what each subagent actually produced.
|
|
141
|
+
const raw = String(out);
|
|
142
|
+
const m = /^agent_id:\s*(\S+)\s*\n+([\s\S]*)$/.exec(raw);
|
|
143
|
+
const body = (m ? m[2] : raw).trim();
|
|
144
|
+
const head = `--- #${i + 1}${job.kind === 'resume' ? ` (resumed ${job.agentId})` : ''}${m ? ` [${m[1]}]` : ''} ---`;
|
|
145
|
+
results[i] = `${head}\n${body}`;
|
|
146
|
+
} catch (e) {
|
|
147
|
+
results[i] = `--- #${i + 1} ---\n[error: ${e.message}]`;
|
|
148
|
+
}
|
|
149
|
+
// Stream progress into the task output so a long swarm is inspectable.
|
|
150
|
+
const done = results.filter((r) => r !== null).length;
|
|
151
|
+
appendTaskOutput(task, `[${done}/${jobs.length}] finished\n`);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const runAll = Promise.all(Array.from({ length: Math.min(SWARM_CONCURRENCY, jobs.length) }, worker));
|
|
155
|
+
|
|
156
|
+
// Ctrl+B hands the still-running swarm to the background. The task already
|
|
157
|
+
// exists, so detaching only flips who waits on `runAll`: the turn stops
|
|
158
|
+
// blocking and keeps the normal progress lines flowing into the task output.
|
|
159
|
+
let detached = false;
|
|
160
|
+
if (ctx) {
|
|
161
|
+
ctx._foreground = {
|
|
162
|
+
kind: 'AgentSwarm',
|
|
163
|
+
description: `${description} (${jobs.length} subagents)`,
|
|
164
|
+
detach: () => {
|
|
165
|
+
if (detached) return task.taskId;
|
|
166
|
+
detached = true;
|
|
167
|
+
task.detached = true;
|
|
168
|
+
if (ctx._foreground && ctx._foreground.detach) ctx._foreground = null;
|
|
169
|
+
void (async () => {
|
|
170
|
+
try {
|
|
171
|
+
await runAll;
|
|
172
|
+
settleTask(task, 'completed');
|
|
173
|
+
appendTaskOutput(task, results.join('\n\n'));
|
|
174
|
+
} catch (e) {
|
|
175
|
+
settleTask(task, 'failed', { stopReason: e.message });
|
|
176
|
+
}
|
|
177
|
+
if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
|
|
178
|
+
})();
|
|
179
|
+
return task.taskId;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await runAll;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (ctx && ctx._foreground && ctx._foreground.detach) ctx._foreground = null;
|
|
188
|
+
// Detached: the background continuation owns the outcome now.
|
|
189
|
+
if (detached) return `Moved to background as task #${task.taskId}. It keeps running; inspect with TaskList / TaskOutput {task_id: "${task.taskId}"}.`;
|
|
190
|
+
settleTask(task, 'failed', { stopReason: e.message });
|
|
191
|
+
return `Error: swarm failed: ${e.message}`;
|
|
192
|
+
}
|
|
193
|
+
if (ctx && ctx._foreground && ctx._foreground.detach) ctx._foreground = null;
|
|
194
|
+
if (detached) {
|
|
195
|
+
return `Moved to background as task #${task.taskId}. It keeps running; inspect with TaskList / TaskOutput {task_id: "${task.taskId}"}.`;
|
|
196
|
+
}
|
|
197
|
+
settleTask(task, 'completed');
|
|
198
|
+
appendTaskOutput(task, results.join('\n\n'));
|
|
199
|
+
if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
|
|
200
|
+
|
|
201
|
+
return `Swarm finished: ${jobs.length} subagent(s).\n\n${results.join('\n\n')}`;
|
|
202
|
+
},
|
|
203
|
+
};
|