@hncode/hncode 0.2.0 → 0.3.1
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 +1 -0
- package/package.json +1 -1
- package/src/agent-task.js +178 -0
- package/src/agent.js +101 -8
- package/src/colors.js +14 -0
- package/src/config.js +191 -10
- package/src/index.js +31 -9
- package/src/llm.js +60 -11
- package/src/plugin.js +25 -2
- package/src/prompt-presets.js +429 -0
- package/src/session.js +257 -24
- 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 +208 -0
- package/src/tools/agent.js +342 -0
- package/src/tools/ask-user-question.js +206 -0
- package/src/tools/bash.js +53 -29
- package/src/tools/edit.js +122 -11
- package/src/tools/file-lines.js +41 -8
- package/src/tools/glob.js +17 -5
- package/src/tools/grep.js +16 -3
- package/src/tools/index.js +19 -2
- package/src/tools/matchers.js +75 -71
- package/src/tools/read-media-file.js +17 -3
- package/src/tools/read.js +2 -0
- package/src/tools/tasks.js +141 -126
- package/src/tools/todo.js +29 -2
- package/src/tools/utils.js +11 -1
- package/src/tui.js +2264 -337
- package/src/update.js +95 -0
- package/src/version.js +20 -0
package/src/session.js
CHANGED
|
@@ -10,6 +10,43 @@ import crypto from 'node:crypto';
|
|
|
10
10
|
const DEFAULT_DIR = () => path.join(os.homedir(), '.hncode', 'sessions');
|
|
11
11
|
function dir(custom) { return custom || process.env.HNCODE_SESSIONS_DIR || DEFAULT_DIR(); }
|
|
12
12
|
|
|
13
|
+
// ---- metadata cache --------------------------------------------------------
|
|
14
|
+
// Enumerating sessions (listSessions / latestSession / --continue) re-read every
|
|
15
|
+
// session file to grab its id/title/workspace/timestamps. With hundreds of
|
|
16
|
+
// sessions that is hundreds of open+read+parse per call. Since session files are
|
|
17
|
+
// only ever written through THIS module (saveSession/deleteSession), we cache the
|
|
18
|
+
// parsed metadata per directory and invalidate the affected entries on write. A
|
|
19
|
+
// caller that knows the dir changed externally (another hncode instance) can call
|
|
20
|
+
// invalidateSessionCache(). The cache never stores message content — only the
|
|
21
|
+
// cheap metadata head.
|
|
22
|
+
const _metaCache = new Map(); // key: storeDir (string) -> Map<fullPath, {meta, hasMessages}>
|
|
23
|
+
function cacheFor(storeDir, create = true) {
|
|
24
|
+
const d = dir(storeDir);
|
|
25
|
+
let c = _metaCache.get(d);
|
|
26
|
+
if (!c && create) { c = new Map(); _metaCache.set(d, c); }
|
|
27
|
+
return c;
|
|
28
|
+
}
|
|
29
|
+
export function invalidateSessionCache(storeDir) {
|
|
30
|
+
_metaCache.delete(dir(storeDir));
|
|
31
|
+
}
|
|
32
|
+
// Public types do not change; this is used by the TUI/headless before a re-list
|
|
33
|
+
// if external state is suspected. Our own save/delete already invalidate.
|
|
34
|
+
|
|
35
|
+
// FIX: single place for the error log so save/load failures are never silent.
|
|
36
|
+
function errorLogFile() {
|
|
37
|
+
return path.join(os.homedir(), '.hncode', 'session-errors.log');
|
|
38
|
+
}
|
|
39
|
+
function logSessionError(op, detail) {
|
|
40
|
+
try {
|
|
41
|
+
fs.mkdirSync(path.dirname(errorLogFile()), { recursive: true });
|
|
42
|
+
fs.appendFileSync(
|
|
43
|
+
errorLogFile(),
|
|
44
|
+
`[${new Date().toISOString()}] ${op}: ${detail}\n`,
|
|
45
|
+
'utf8',
|
|
46
|
+
);
|
|
47
|
+
} catch { /* logging must never throw */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
13
50
|
export function newId() {
|
|
14
51
|
// compact, mostly-sorted id (like a timestamp+rand ULID-ish)
|
|
15
52
|
return Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
|
|
@@ -19,55 +56,251 @@ export function sessionFile(id, storeDir) {
|
|
|
19
56
|
return path.join(dir(storeDir), `${id}.json`);
|
|
20
57
|
}
|
|
21
58
|
|
|
59
|
+
// FIX: build the serialized object with a FIXED key order so `updatedAt`
|
|
60
|
+
// always lands BEFORE `messages`. readSessionMeta() stops scanning at the
|
|
61
|
+
// `"messages"` key, so a session whose updatedAt sits after the transcript
|
|
62
|
+
// would report updatedAt = 0 and the session list / --continue would sort
|
|
63
|
+
// wrongly (or pick the wrong session). Key order here is authoritative.
|
|
64
|
+
function serializableSession(session) {
|
|
65
|
+
const out = {};
|
|
66
|
+
out.id = session.id;
|
|
67
|
+
out.title = session.title || '';
|
|
68
|
+
out.workspace = session.workspace || '';
|
|
69
|
+
out.model = session.model || '';
|
|
70
|
+
out.createdAt = session.createdAt || Date.now();
|
|
71
|
+
out.updatedAt = Date.now();
|
|
72
|
+
// Everything else (messages, rounds, steps, plan, focus, swarm, effort, …)
|
|
73
|
+
// is copied AFTER updatedAt. `messages` in particular must come after the
|
|
74
|
+
// metadata block.
|
|
75
|
+
for (const k of Object.keys(session)) {
|
|
76
|
+
if (k in out) continue;
|
|
77
|
+
if (k === 'messages') continue; // handled below, last
|
|
78
|
+
out[k] = session[k];
|
|
79
|
+
}
|
|
80
|
+
out.messages = Array.isArray(session.messages) ? session.messages : [];
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
22
84
|
export function saveSession(session, storeDir) {
|
|
85
|
+
if (!session || !session.id) return session;
|
|
86
|
+
// Keep the live object's updatedAt in sync for callers that read it back.
|
|
23
87
|
session.updatedAt = Date.now();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
88
|
+
try {
|
|
89
|
+
fs.mkdirSync(dir(storeDir), { recursive: true });
|
|
90
|
+
const target = sessionFile(session.id, storeDir);
|
|
91
|
+
// FIX: unique temp name so two concurrent saves of the same session cannot
|
|
92
|
+
// clobber each other's temp file mid-write.
|
|
93
|
+
const tmp = `${target}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
|
|
94
|
+
const json = JSON.stringify(serializableSession(session), null, 2);
|
|
95
|
+
fs.writeFileSync(tmp, json, 'utf8');
|
|
96
|
+
// Atomic replace. If this throws (EXDEV etc.) the temp is cleaned up below.
|
|
97
|
+
fs.renameSync(tmp, target);
|
|
98
|
+
// The saved transcript changed, so its cached metadata head is stale. Drop
|
|
99
|
+
// just this entry; the next scan re-reads the file (cheap, one file).
|
|
100
|
+
cacheFor(storeDir, false)?.delete(sessionFile(session.id, storeDir));
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// FIX: never fail silently. Previously an empty catch {} in the TUI meant a
|
|
103
|
+
// serialization error (circular ref, disk full, permissions) lost the turn
|
|
104
|
+
// with no signal at all.
|
|
105
|
+
logSessionError('save', `${session.id}: ${e && e.message ? e.message : String(e)}`);
|
|
106
|
+
return session;
|
|
107
|
+
}
|
|
28
108
|
return session;
|
|
29
109
|
}
|
|
30
110
|
|
|
31
111
|
export function loadSession(id, storeDir) {
|
|
32
|
-
|
|
33
|
-
|
|
112
|
+
const file = sessionFile(id, storeDir);
|
|
113
|
+
let text;
|
|
114
|
+
try {
|
|
115
|
+
text = fs.readFileSync(file, 'utf8');
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// ENOENT is "no such session" (normal). Anything else is a real read error.
|
|
118
|
+
if (e && e.code !== 'ENOENT') logSessionError('load', `${id}: ${e.message}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(text);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// FIX: a corrupt file is NOT the same as a missing session. Log it so the
|
|
125
|
+
// user can find the damaged file instead of silently "losing" the session.
|
|
126
|
+
logSessionError('parse', `${id}: ${e.message} (file: ${file})`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
34
129
|
}
|
|
35
130
|
|
|
36
131
|
// Most recent session for a workspace, or globally.
|
|
37
132
|
// `opts.skipEmpty` ignores sessions with no messages: hncode creates an empty
|
|
38
133
|
// session shell on every launch, so without this `--continue` would "resume"
|
|
39
134
|
// a brand-new empty session instead of the user's last real conversation.
|
|
135
|
+
//
|
|
136
|
+
// Only metadata is read (see readSessionMeta): the full parse was 370 ms on 40
|
|
137
|
+
// realistic sessions, pure waste since the caller needs a title and a timestamp.
|
|
40
138
|
export function latestSession(cwd, storeDir, opts = {}) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
let best = null;
|
|
139
|
+
const entries = scanCached(storeDir);
|
|
140
|
+
const wantWs = cwd !== undefined ? path.resolve(cwd) : null;
|
|
141
|
+
let best = null; // { meta, file }
|
|
45
142
|
let bestEmpty = null;
|
|
46
|
-
|
|
143
|
+
const stamp = (m) => (m && (m.updatedAt || m.createdAt)) || 0;
|
|
144
|
+
for (const { meta, hasMessages, file } of entries) {
|
|
145
|
+
if (wantWs !== null && meta.workspace !== wantWs) continue;
|
|
146
|
+
const up = stamp(meta);
|
|
147
|
+
if (!best || up > stamp(best.meta)) best = { meta, file };
|
|
148
|
+
// hasMessages comes from the same cache entry (single head-read per file),
|
|
149
|
+
// so we do not reopen anything to test emptiness.
|
|
150
|
+
if (hasMessages && (!bestEmpty || up > stamp(bestEmpty.meta))) bestEmpty = { meta, file };
|
|
151
|
+
}
|
|
152
|
+
const pick = opts.skipEmpty ? (bestEmpty || best) : best;
|
|
153
|
+
if (!pick) return null;
|
|
154
|
+
// The caller wants a usable session, so load the chosen one in full — one file,
|
|
155
|
+
// not forty.
|
|
156
|
+
const loaded = loadSession(pick.meta.id || path.basename(pick.file, '.json'), storeDir);
|
|
157
|
+
if (loaded) return loaded;
|
|
158
|
+
// If the full load failed (corrupt), fall back to the metadata we already have
|
|
159
|
+
// so the caller still gets an id/title/workspace instead of null.
|
|
160
|
+
return pick.meta;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Enumerate the session directory, reading each file's metadata head ONCE per
|
|
164
|
+
// file per process (cached). Repeated calls — listSessions then latestSession,
|
|
165
|
+
// or the /sessions picker — reuse the parsed metadata instead of re-opening
|
|
166
|
+
// every file. Entries are deleted from the cache when saveSession/deleteSession
|
|
167
|
+
// touches that file, and stale paths are pruned here when the directory shrinks.
|
|
168
|
+
// Returns [{ file, meta, hasMessages }].
|
|
169
|
+
function scanCached(storeDir) {
|
|
170
|
+
const d = dir(storeDir);
|
|
171
|
+
const map = cacheFor(storeDir);
|
|
172
|
+
let names;
|
|
173
|
+
try { names = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { map.clear(); return []; }
|
|
174
|
+
// Prune cached paths that no longer exist on disk.
|
|
175
|
+
const live = new Set(names);
|
|
176
|
+
for (const p of map.keys()) if (!live.has(path.basename(p))) map.delete(p);
|
|
177
|
+
// Read only the files we have not cached yet.
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const f of names) {
|
|
180
|
+
const full = path.join(d, f);
|
|
181
|
+
let got = map.get(full);
|
|
182
|
+
if (!got) {
|
|
183
|
+
got = _readMetaOnce(full);
|
|
184
|
+
if (got) map.set(full, got);
|
|
185
|
+
}
|
|
186
|
+
if (got) out.push({ file: full, meta: got.meta, hasMessages: got.hasMessages });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Read ONLY a session's metadata (id/title/workspace/timestamps), never its
|
|
192
|
+
// messages — and never the whole FILE. Two costs had to go:
|
|
193
|
+
// * `JSON.parse` of the entire session: listing 40 realistic sessions measured
|
|
194
|
+
// 528 ms, `--continue` 370 ms, to read a title and a date;
|
|
195
|
+
// * even `readFileSync` of the whole file: a session with a long transcript is
|
|
196
|
+
// megabytes, and pulling it into a string is itself the stall.
|
|
197
|
+
//
|
|
198
|
+
// FIX: the previous version scanned for the bare `"messages"` key and stopped,
|
|
199
|
+
// which is wrong in two ways:
|
|
200
|
+
// 1. it assumes no metadata follows messages, but saveSession USED TO append
|
|
201
|
+
// `updatedAt` after `messages` (object key insertion order), so updatedAt
|
|
202
|
+
// was never seen and every session sorted as 0;
|
|
203
|
+
// 2. it cut the head mid-key, so the JSON.parse fixup was fragile.
|
|
204
|
+
// Now we stop at `"messages": [` (the array VALUE), not the key, so any key
|
|
205
|
+
// between the metadata block and the messages array is still included.
|
|
206
|
+
//
|
|
207
|
+
// Returns null when the file is not a readable session.
|
|
208
|
+
const META_HEAD_BYTES = 64 * 1024;
|
|
209
|
+
const META_MAX_BYTES = 4 * 1024 * 1024; // give up rather than scan a giant file
|
|
210
|
+
|
|
211
|
+
export function readSessionMeta(file) {
|
|
212
|
+
const res = _readMetaOnce(file);
|
|
213
|
+
return res ? res.meta : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Metadata for `file`, plus whether it holds any message. Reads the file head
|
|
217
|
+
// ONE time: latestSession needs both the timestamp (to sort) and the
|
|
218
|
+
// non-empty rule (for --continue's skipEmpty). Doing them in two passes —
|
|
219
|
+
// readSessionMeta then hasMessages — opened every candidate file twice.
|
|
220
|
+
// Returns { meta, hasMessages } or null.
|
|
221
|
+
function _readMetaOnce(file) {
|
|
222
|
+
let fd;
|
|
223
|
+
try { fd = fs.openSync(file, 'r'); } catch { return null; }
|
|
224
|
+
try {
|
|
225
|
+
const buf = Buffer.allocUnsafe(META_HEAD_BYTES);
|
|
226
|
+
let text = '';
|
|
227
|
+
let read = 0;
|
|
228
|
+
let cut = -1;
|
|
229
|
+
// Grow in chunks until the `"messages"` VALUE starts, or the cap is hit.
|
|
230
|
+
for (;;) {
|
|
231
|
+
const n = fs.readSync(fd, buf, 0, buf.length, read);
|
|
232
|
+
if (n <= 0) break;
|
|
233
|
+
text += buf.toString('utf8', 0, n);
|
|
234
|
+
read += n;
|
|
235
|
+
cut = findMessagesValueStart(text);
|
|
236
|
+
if (cut >= 0 || read >= META_MAX_BYTES) break;
|
|
237
|
+
}
|
|
238
|
+
// Non-empty check from the SAME buffered head: true when a real "role" line
|
|
239
|
+
// follows the messages-array opening. Mirrors hasMessages().
|
|
240
|
+
const nonEmpty = !!(cut >= 0 && /"role"/.test(text.slice(cut)));
|
|
241
|
+
// Take everything BEFORE the messages array (which is the metadata block),
|
|
242
|
+
// then close the truncated JSON object.
|
|
243
|
+
const head = cut > 0 ? text.slice(0, cut) : text;
|
|
47
244
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
245
|
+
// `head` ends right after `"messages":` (no value), so append `[]}` to
|
|
246
|
+
// close it cleanly. Also handle the case where the array value already
|
|
247
|
+
// started but the head stops elsewhere.
|
|
248
|
+
const json = head.replace(/,\s*$/, '').replace(/"messages"\s*:\s*$/, '"messages": []')
|
|
249
|
+
+ (head.trimEnd().endsWith('{') || head.trimEnd().endsWith(',') ? '}' : '}');
|
|
250
|
+
const parsed = JSON.parse(json);
|
|
251
|
+
delete parsed.messages;
|
|
252
|
+
return { meta: parsed, hasMessages: nonEmpty };
|
|
253
|
+
} catch {
|
|
254
|
+
// Fallback: parse as much of the file as we have, drop messages.
|
|
255
|
+
try {
|
|
256
|
+
const m = JSON.parse(text.slice(0, text.lastIndexOf('}') + 1));
|
|
257
|
+
delete m.messages;
|
|
258
|
+
return { meta: m, hasMessages: nonEmpty };
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
} finally {
|
|
266
|
+
try { fs.closeSync(fd); } catch {}
|
|
54
267
|
}
|
|
55
|
-
if (opts.skipEmpty) return bestEmpty || best;
|
|
56
|
-
return best;
|
|
57
268
|
}
|
|
58
269
|
|
|
270
|
+
// Locate the index of the character right after `"messages"` (whitespace and
|
|
271
|
+
// the `:` included) so the metadata head can be closed with `"messages": []}`.
|
|
272
|
+
// Returns -1 when the key is not present in `text`.
|
|
273
|
+
function findMessagesValueStart(text) {
|
|
274
|
+
const key = text.indexOf('"messages"');
|
|
275
|
+
if (key < 0) return -1;
|
|
276
|
+
let i = key + '"messages"'.length;
|
|
277
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
278
|
+
if (text[i] === ':') i++;
|
|
279
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
280
|
+
return i; // may point at `[` (or be end-of-text; caller handles both)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Non-empty detection now lives in _readMetaOnce (it reads the file head once
|
|
284
|
+
// for both the metadata and the emptiness rule), so the old separate
|
|
285
|
+
// hasMessages() scan is gone — latestSession no longer reopens each candidate.
|
|
286
|
+
|
|
287
|
+
// The session list for the /sessions picker. Metadata only — see readSessionMeta
|
|
288
|
+
// for why. Entries carry no `messages`, which every caller already treats as
|
|
289
|
+
// "not loaded yet".
|
|
59
290
|
export function listSessions(storeDir) {
|
|
60
|
-
const d = dir(storeDir);
|
|
61
|
-
let files;
|
|
62
|
-
try { files = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
63
291
|
const out = [];
|
|
64
|
-
for (const
|
|
65
|
-
|
|
292
|
+
for (const { meta } of scanCached(storeDir)) {
|
|
293
|
+
// FIX: fall back to createdAt so a legacy session written with a
|
|
294
|
+
// post-messages updatedAt still sorts reasonably instead of as 0.
|
|
295
|
+
if (!meta.updatedAt && meta.createdAt) meta.updatedAt = meta.createdAt;
|
|
296
|
+
out.push(meta);
|
|
66
297
|
}
|
|
67
298
|
out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
68
299
|
return out;
|
|
69
300
|
}
|
|
70
301
|
|
|
71
302
|
export function deleteSession(id, storeDir) {
|
|
303
|
+
// Drop the cached entry if present (before unlink, so the path is known).
|
|
304
|
+
try { cacheFor(storeDir, false)?.delete(sessionFile(id, storeDir)); } catch {}
|
|
72
305
|
try { fs.unlinkSync(sessionFile(id, storeDir)); return true; } catch { return false; }
|
|
73
306
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Subagent type catalog, shared by the Agent tool and AgentSwarm.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors kimi-code's builtin agent profiles (session/agentLifecycle/profile/profiles.ts):
|
|
4
|
+
// coder - general software engineering; the only type with editing tools
|
|
5
|
+
// explore - fast read-only codebase exploration (kimi calls it "explore")
|
|
6
|
+
// plan - read-only implementation planning; no shell, no editing
|
|
7
|
+
//
|
|
8
|
+
// Kept in its own module so agent.js and agent-swarm.js can both import it
|
|
9
|
+
// without a cycle (agent-swarm used to import it from agent.js, which then
|
|
10
|
+
// imported agent-swarm back — the constant was read before it was initialised).
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_SUBAGENT_TYPE = 'coder';
|
|
13
|
+
|
|
14
|
+
// Shared prefix kimi puts on every task-agent role prompt. It is what stops a
|
|
15
|
+
// subagent from addressing the end user directly or assuming the parent can see
|
|
16
|
+
// its context.
|
|
17
|
+
const TASK_AGENT_ROLE_PREFIX =
|
|
18
|
+
'You are now running as a subagent. All the `user` messages are sent by the main agent. '
|
|
19
|
+
+ 'The main agent cannot see your context, it can only see your last message when you finish the task. '
|
|
20
|
+
+ 'You must treat the parent agent as your caller. Do not directly ask the end user questions. '
|
|
21
|
+
+ 'If something is unclear, explain the ambiguity in your final summary to the parent agent. '
|
|
22
|
+
// A subagent has no Agent/AgentSwarm in its toolbelt (see DELEGATION_TOOLS), so it
|
|
23
|
+
// must not try: a delegated delegation would have nobody to report to and would
|
|
24
|
+
// nest without bound.
|
|
25
|
+
+ 'You cannot delegate: you have no Agent or AgentSwarm tool. Do all of the work yourself.';
|
|
26
|
+
|
|
27
|
+
// Tools that let an agent hand work to ANOTHER agent. Subagents never get these:
|
|
28
|
+
// delegation belongs to the top-level turn the user is watching. kimi expresses
|
|
29
|
+
// this through each profile's explicit `tools` allowlist; we enforce it the same
|
|
30
|
+
// way plus a hard block, so a future profile that forgets to list its tools
|
|
31
|
+
// cannot silently re-enable nesting.
|
|
32
|
+
export const DELEGATION_TOOLS = ['Agent', 'AgentSwarm'];
|
|
33
|
+
|
|
34
|
+
export const SUBAGENT_TYPES = {
|
|
35
|
+
coder: {
|
|
36
|
+
label: 'Coder',
|
|
37
|
+
description: 'General software engineering agent — the only subagent type with file-editing tools; use it for any delegated task that must modify code.',
|
|
38
|
+
whenToUse:
|
|
39
|
+
'Use this agent for non-trivial software engineering work that may require reading files, editing code, '
|
|
40
|
+
+ 'running commands, and returning a compact but technically complete summary to the parent agent.',
|
|
41
|
+
// An explicit allowlist (not `null` = everything) so Agent/AgentSwarm are
|
|
42
|
+
// excluded. Mirrors kimi's CODER_TOOLS.
|
|
43
|
+
tools: [
|
|
44
|
+
'Read', 'Write', 'Edit', 'Glob', 'Grep', 'FileLines', 'Bash',
|
|
45
|
+
'TodoList', 'FetchURL', 'WebSearch', 'ReadMediaFile',
|
|
46
|
+
'TaskList', 'TaskOutput', 'TaskStop', 'TaskWait',
|
|
47
|
+
],
|
|
48
|
+
prompt:
|
|
49
|
+
`${TASK_AGENT_ROLE_PREFIX}\n\n`
|
|
50
|
+
+ 'Your final message is the entire handoff — the parent sees nothing else from your run. '
|
|
51
|
+
+ 'Make it technically complete: what you changed and why, the path of every file you touched, '
|
|
52
|
+
+ 'how you verified the change (tests or commands run, with results), and anything left undone '
|
|
53
|
+
+ 'or worth follow-up. If you are stopped before finishing, the parent receives only what '
|
|
54
|
+
+ 'you have written so far, so keep the handoff current.',
|
|
55
|
+
},
|
|
56
|
+
explore: {
|
|
57
|
+
label: 'Explore',
|
|
58
|
+
description: 'Fast codebase exploration with prompt-enforced read-only behavior.',
|
|
59
|
+
whenToUse:
|
|
60
|
+
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns '
|
|
61
|
+
+ '(e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about '
|
|
62
|
+
+ 'the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired '
|
|
63
|
+
+ 'thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for '
|
|
64
|
+
+ 'comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only '
|
|
65
|
+
+ 'exploration that will clearly require more than 3 search queries. Prefer launching multiple explore agents '
|
|
66
|
+
+ 'concurrently when investigating independent questions.',
|
|
67
|
+
// Bash is allowed for READ-ONLY commands (git log, ls, find) — the prompt
|
|
68
|
+
// forbids using it to create or modify anything, exactly as kimi does.
|
|
69
|
+
tools: ['Read', 'ReadMediaFile', 'Glob', 'Grep', 'Bash', 'WebSearch', 'FetchURL'],
|
|
70
|
+
prompt:
|
|
71
|
+
`${TASK_AGENT_ROLE_PREFIX}\n\n`
|
|
72
|
+
+ 'You are a codebase exploration specialist. Your role is EXCLUSIVELY to search, read, and analyze existing '
|
|
73
|
+
+ 'code and resources. You do NOT have access to file editing tools.\n\n'
|
|
74
|
+
+ 'Your strengths:\n'
|
|
75
|
+
+ '- Rapidly finding files using glob patterns\n'
|
|
76
|
+
+ '- Searching code and text with powerful regex patterns\n'
|
|
77
|
+
+ '- Reading and analyzing file contents\n'
|
|
78
|
+
+ '- Running read-only shell commands (git log, git diff, ls, find, etc.)\n\n'
|
|
79
|
+
+ 'Guidelines:\n'
|
|
80
|
+
+ '- Use Glob for broad file pattern matching. Prefer patterns with a literal anchor (extension or subdirectory); '
|
|
81
|
+
+ 'pure wildcards like `*` or `**/*` are allowed but usually truncate at the match cap.\n'
|
|
82
|
+
+ '- Use Grep for searching file contents with regex\n'
|
|
83
|
+
+ '- Use Read when you know the specific file path\n'
|
|
84
|
+
+ '- Use Bash ONLY for read-only operations (ls, git status, git log, git diff, find)\n'
|
|
85
|
+
+ '- NEVER use Bash for any file creation or modification commands\n'
|
|
86
|
+
+ '- Use WebSearch or FetchURL when a question needs external context; the local codebase remains your primary domain\n'
|
|
87
|
+
+ '- Adapt your search depth based on the thoroughness level specified by the caller\n'
|
|
88
|
+
+ '- Wherever possible, spawn multiple parallel tool calls for grepping and reading files to maximize speed\n\n'
|
|
89
|
+
+ 'You are meant to be a fast agent. Complete the search request efficiently and report your findings clearly '
|
|
90
|
+
+ 'in a structured format.',
|
|
91
|
+
},
|
|
92
|
+
plan: {
|
|
93
|
+
label: 'Plan',
|
|
94
|
+
description: 'Read-only implementation planning and architecture design.',
|
|
95
|
+
whenToUse:
|
|
96
|
+
'Use this agent when the parent agent needs a step-by-step implementation plan, key file identification, '
|
|
97
|
+
+ 'and architectural trade-off analysis before code changes are made.',
|
|
98
|
+
// No Bash at all — a planning agent has no shell.
|
|
99
|
+
tools: ['Read', 'ReadMediaFile', 'Glob', 'Grep', 'WebSearch', 'FetchURL'],
|
|
100
|
+
prompt:
|
|
101
|
+
`${TASK_AGENT_ROLE_PREFIX}\n\n`
|
|
102
|
+
+ 'Before designing your implementation plan, consider whether you fully understand the codebase areas '
|
|
103
|
+
+ 'relevant to the task. If not, recommend the parent agent to use the explore agent '
|
|
104
|
+
+ '(subagent_type="explore") to investigate key questions first. In your response, clearly state:\n'
|
|
105
|
+
+ '1. What you already know from the information provided\n'
|
|
106
|
+
+ '2. What questions remain unanswered that would benefit from explore agent investigation\n'
|
|
107
|
+
+ '3. Your implementation plan (either preliminary if questions remain, or final if sufficient context exists)\n\n'
|
|
108
|
+
+ 'You are a read-only planning agent: you can read and search files and consult the web, but you have no '
|
|
109
|
+
+ 'shell and no file-editing tools. Where the general instructions tell you to make changes with tools, that '
|
|
110
|
+
+ 'does not apply to you — do not attempt to run commands or modify files. Your deliverable is the plan '
|
|
111
|
+
+ 'itself, returned as your final message.',
|
|
112
|
+
},
|
|
113
|
+
researcher: {
|
|
114
|
+
label: 'Researcher',
|
|
115
|
+
description: 'Read-only investigation; reports findings, changes nothing.',
|
|
116
|
+
whenToUse:
|
|
117
|
+
'Use this agent for read-only investigation on the web or in the repository when you want findings '
|
|
118
|
+
+ 'reported back without any chance of local modification.',
|
|
119
|
+
tools: ['Read', 'ReadMediaFile', 'Grep', 'Glob', 'WebSearch', 'FetchURL'],
|
|
120
|
+
prompt:
|
|
121
|
+
`${TASK_AGENT_ROLE_PREFIX}\n\n`
|
|
122
|
+
+ 'You are a research subagent. Investigate with the read-only tools and report findings. '
|
|
123
|
+
+ 'Do NOT modify anything.',
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// The catalog as the Agent tool describes it: one line per type, description and
|
|
128
|
+
// the "when to use" guidance the model needs to pick correctly.
|
|
129
|
+
export function availableTypes() {
|
|
130
|
+
return Object.entries(SUBAGENT_TYPES)
|
|
131
|
+
.map(([id, t]) => `- ${id}: ${t.description}${t.whenToUse ? `\n ${t.whenToUse}` : ''}`)
|
|
132
|
+
.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The tool allowlist a subagent of `type` may run. Always excludes the delegation
|
|
136
|
+
// tools, whatever the profile declares, so nesting is impossible.
|
|
137
|
+
export function toolsForSubagent(type, parentFilter) {
|
|
138
|
+
const profile = SUBAGENT_TYPES[type] || SUBAGENT_TYPES[DEFAULT_SUBAGENT_TYPE];
|
|
139
|
+
const base = Array.isArray(profile.tools) ? profile.tools : null;
|
|
140
|
+
if (base) return base.filter((t) => !DELEGATION_TOOLS.includes(t));
|
|
141
|
+
// No explicit list: derive it from the parent's filter so a narrowed parent
|
|
142
|
+
// (Plan / Focus mode) stays narrowed in the child, minus delegation.
|
|
143
|
+
if (Array.isArray(parentFilter) && parentFilter.length) {
|
|
144
|
+
return parentFilter.filter((t) => !DELEGATION_TOOLS.includes(t));
|
|
145
|
+
}
|
|
146
|
+
return null; // null = agent.js exposes everything except DELEGATION_TOOLS
|
|
147
|
+
}
|