@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,335 @@
|
|
|
1
|
+
// Agent tool — launch a subagent to handle a task, mirroring kimi-code's Agent.
|
|
2
|
+
//
|
|
3
|
+
// A subagent is a full Agent instance (same toolbelt, same protocol) with its OWN
|
|
4
|
+
// message list. The point is context isolation: the parent gets a conclusion back
|
|
5
|
+
// instead of a pile of file dumps.
|
|
6
|
+
//
|
|
7
|
+
// Supports, like kimi:
|
|
8
|
+
// prompt / description the task
|
|
9
|
+
// subagent_type profile name (we keep a small catalog)
|
|
10
|
+
// resume continue an earlier subagent by agent id
|
|
11
|
+
// run_in_background start a background task and return its id
|
|
12
|
+
// fork start from a snapshot of THIS conversation
|
|
13
|
+
// model run on another configured model
|
|
14
|
+
//
|
|
15
|
+
// The result is returned as a plain string. A subagent's output is only visible
|
|
16
|
+
// to the CALLER, never rendered to the user directly — the parent is expected to
|
|
17
|
+
// summarise it.
|
|
18
|
+
|
|
19
|
+
import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
|
|
20
|
+
import { SUBAGENT_TYPES, DEFAULT_SUBAGENT_TYPE, availableTypes, toolsForSubagent } from '../subagent-types.js';
|
|
21
|
+
|
|
22
|
+
// Live subagent registry, keyed by agent id. A resumed agent keeps its own
|
|
23
|
+
// message list here, which is what makes `resume` work.
|
|
24
|
+
const subagents = new Map();
|
|
25
|
+
let agentSeq = 0;
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
export function newAgentId() {
|
|
29
|
+
agentSeq = (agentSeq + 1) % 1e6;
|
|
30
|
+
return `agent-${Date.now().toString(36)}${String(agentSeq).padStart(3, '0')}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getSubagent(id) {
|
|
34
|
+
return subagents.get(id) || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function listSubagents() {
|
|
38
|
+
return [...subagents.values()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Build the system prompt for a subagent: its profile prompt plus the shared
|
|
42
|
+
// rules, so a subagent behaves like the main agent within its remit.
|
|
43
|
+
function systemPromptFor(type, cfg, systemPrompt) {
|
|
44
|
+
const profile = SUBAGENT_TYPES[type] || SUBAGENT_TYPES[DEFAULT_SUBAGENT_TYPE];
|
|
45
|
+
const base = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || systemPrompt;
|
|
46
|
+
return `${profile.prompt}\n\n${base}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Run a subagent to completion. `history` seeds its messages (fork). Returns
|
|
50
|
+
// { text, usage } or { error }.
|
|
51
|
+
// `onProgress(text)` receives the subagent's live output (its reasoning and answer
|
|
52
|
+
// stream, and each tool it runs) so the TUI can show it under the `Using Agent`
|
|
53
|
+
// row the way Bash's output is shown. `agentId` labels the lines when several
|
|
54
|
+
// subagents run at once (AgentSwarm), so the reader can tell them apart.
|
|
55
|
+
async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProgress, agentId }) {
|
|
56
|
+
const { Agent, SYSTEM_PROMPT } = await import('../agent.js');
|
|
57
|
+
const subCfg = {
|
|
58
|
+
...cfg,
|
|
59
|
+
systemPrompt: systemPromptFor(type, cfg, SYSTEM_PROMPT),
|
|
60
|
+
// The profile narrows the toolbelt (explore / plan / researcher are read-only)
|
|
61
|
+
// and the delegation tools are removed unconditionally — a subagent must not
|
|
62
|
+
// start another agent. `noDelegation` is the hard backstop in agent.js for the
|
|
63
|
+
// case where the model emits a call for a tool it no longer has.
|
|
64
|
+
toolFilter: toolsForSubagent(type, cfg.toolFilter),
|
|
65
|
+
noDelegation: true,
|
|
66
|
+
};
|
|
67
|
+
// fork: the snapshot comes first, then the new instruction.
|
|
68
|
+
const messages = Array.isArray(history) && history.length
|
|
69
|
+
? [...history, { role: 'user', content: prompt }]
|
|
70
|
+
: [{ role: 'user', content: prompt }];
|
|
71
|
+
|
|
72
|
+
let text = '';
|
|
73
|
+
const agent = new Agent({
|
|
74
|
+
cfg: subCfg,
|
|
75
|
+
messages,
|
|
76
|
+
onEvent: (e) => {
|
|
77
|
+
if (e.type === 'data') text += e.text;
|
|
78
|
+
// Mirror what the subagent is actually doing into the parent's transcript:
|
|
79
|
+
// its reasoning and answer stream as `data`/`think`, and every tool it runs
|
|
80
|
+
// arrives as tool_start/tool_result. Without this the `Using Agent …` row sat
|
|
81
|
+
// silent for the whole run — the subagent looked hung.
|
|
82
|
+
if (onProgress) {
|
|
83
|
+
const tag = agentId ? `[${agentId}] ` : '';
|
|
84
|
+
if (e.type === 'data' || e.type === 'think') {
|
|
85
|
+
if (e.text) onProgress(tag + e.text);
|
|
86
|
+
} else if (e.type === 'tool_start') {
|
|
87
|
+
onProgress(`\n${tag}\u25b8 ${e.name}\n`);
|
|
88
|
+
} else if (e.type === 'tool_result') {
|
|
89
|
+
const body = String(e.content == null ? '' : e.content);
|
|
90
|
+
const first = body.split('\n').slice(0, 3).join('\n');
|
|
91
|
+
onProgress(`${tag}${first}${body.split('\n').length > 3 ? '\n\u2026' : ''}\n`);
|
|
92
|
+
} else if (e.type === 'error') {
|
|
93
|
+
onProgress(`\n${tag}[error] ${(e.error && e.error.message) || String(e.error)}\n`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (onEvent) onEvent(e);
|
|
97
|
+
},
|
|
98
|
+
// A subagent never prompts: it inherits the parent's permission mode by
|
|
99
|
+
// running under the same ctx (kimi does the same — approvals belong to the
|
|
100
|
+
// parent turn, not the child).
|
|
101
|
+
onApproval: async () => true,
|
|
102
|
+
});
|
|
103
|
+
if (signal) {
|
|
104
|
+
if (signal.aborted) throw new Error('Interrupted by user');
|
|
105
|
+
signal.addEventListener('abort', () => agent.interrupt(), { once: true });
|
|
106
|
+
}
|
|
107
|
+
await agent.run();
|
|
108
|
+
// The parent must receive ONLY what the subagent concluded — never its tool
|
|
109
|
+
// chatter, and never its reasoning. Two things could leak in:
|
|
110
|
+
// * the aggregate `data` stream, which also contains intermediate narration;
|
|
111
|
+
// * an inline `<think>…</think>` block, when a model puts reasoning in the
|
|
112
|
+
// content stream instead of the separate reasoning channel.
|
|
113
|
+
// So: take the LAST assistant message (the handoff), and strip any think block
|
|
114
|
+
// from it. Fall back to the filtered stream text when there is no message.
|
|
115
|
+
const stripThink = (s) => String(s).replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '').trim();
|
|
116
|
+
const lastAssistant = [...agent.messages].reverse()
|
|
117
|
+
.find((m) => m.role === 'assistant' && typeof m.content === 'string' && stripThink(m.content));
|
|
118
|
+
const result = (lastAssistant && stripThink(lastAssistant.content)) || stripThink(text) || '(no output)';
|
|
119
|
+
return { text: result, messages: agent.messages };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const spec = {
|
|
123
|
+
name: 'Agent',
|
|
124
|
+
description: `Launch a subagent to handle a task. The subagent runs with its own context, so delegating keeps the bulk of intermediate file contents out of your own context — you get a conclusion back instead of a pile of dumps.
|
|
125
|
+
|
|
126
|
+
Writing the prompt:
|
|
127
|
+
- The subagent starts with zero context — it has not seen this conversation. Brief it like a colleague who just walked into the room: state the goal, list what you already know, hand over the specifics.
|
|
128
|
+
- Lookups (read this file, run that test): put the exact path or command in the prompt.
|
|
129
|
+
- Investigations (figure out X, find why Y): give the question, not prescribed steps.
|
|
130
|
+
- Do not delegate understanding. If the task hinges on a file path or line number, find it yourself first and write it into the prompt.
|
|
131
|
+
|
|
132
|
+
Usage notes:
|
|
133
|
+
- When the task continues earlier work a subagent already did, prefer resuming that agent (pass its \`resume\` id) over spawning a fresh instance.
|
|
134
|
+
- A subagent's result is only visible to you, not to the user. Summarize the relevant parts yourself in your own reply.
|
|
135
|
+
|
|
136
|
+
When NOT to use Agent:
|
|
137
|
+
- Skip delegation for trivial work you can already do directly — reading a file whose path you know, or any task that takes a step or two.
|
|
138
|
+
- Do NOT spawn a subagent unless the user, or the applicable AGENTS.md / project instructions, explicitly asks for sub-agents, delegation, or parallel agent work. Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do NOT count as permission to spawn. Doing the work yourself is the default.
|
|
139
|
+
- Do not delegate the step you are about to be blocked on: if your very next action depends on the result, do it yourself and keep the critical path moving.
|
|
140
|
+
- Do not delegate work you are already doing — redelegating an unresumed task duplicates effort instead of saving context.
|
|
141
|
+
|
|
142
|
+
Available agent types:
|
|
143
|
+
${availableTypes()}`,
|
|
144
|
+
parameters: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
prompt: { type: 'string', description: 'Full task prompt for the subagent.' },
|
|
148
|
+
description: { type: 'string', description: 'Short task description (3-5 words) for UI display.' },
|
|
149
|
+
subagent_type: { type: 'string', description: `One of the available agent types. Defaults to "${DEFAULT_SUBAGENT_TYPE}" when omitted.` },
|
|
150
|
+
resume: { type: 'string', description: 'Optional agent id to resume instead of creating a new instance. Do not also pass subagent_type.' },
|
|
151
|
+
run_in_background: { type: 'boolean', default: false, description: 'If true, return immediately with a task id instead of waiting. The result arrives in the conversation later.' },
|
|
152
|
+
fork: { type: 'boolean', default: false, description: "Start the subagent from a snapshot of this agent's completed conversation history instead of zero context." },
|
|
153
|
+
model: { type: 'string', description: 'A configured model key to run the subagent on, or "primary" for the current model. Ignored when resuming.' },
|
|
154
|
+
},
|
|
155
|
+
required: ['prompt', 'description'],
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async execute(args, ctx) {
|
|
159
|
+
const prompt = String(args.prompt || '').trim();
|
|
160
|
+
const description = String(args.description || '').trim();
|
|
161
|
+
if (!prompt) return 'Error: `prompt` is required.';
|
|
162
|
+
if (!description) return 'Error: `description` is required (short label for the task list).';
|
|
163
|
+
|
|
164
|
+
const resumeId = args.resume ? String(args.resume).trim() : '';
|
|
165
|
+
const wantFork = args.fork === true;
|
|
166
|
+
if (resumeId && args.subagent_type) {
|
|
167
|
+
return 'Error: cannot set subagent_type when resuming an existing agent. Resume by agent id only.';
|
|
168
|
+
}
|
|
169
|
+
if (resumeId && wantFork) {
|
|
170
|
+
return 'Error: `fork` cannot be combined with `resume`.';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- resume an existing subagent -------------------------------------
|
|
174
|
+
let existing = null;
|
|
175
|
+
if (resumeId) {
|
|
176
|
+
existing = subagents.get(resumeId);
|
|
177
|
+
if (!existing) return `Error: no such subagent: ${resumeId}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const type = existing
|
|
181
|
+
? existing.type
|
|
182
|
+
: (SUBAGENT_TYPES[args.subagent_type] ? args.subagent_type : DEFAULT_SUBAGENT_TYPE);
|
|
183
|
+
|
|
184
|
+
// ---- model override ---------------------------------------------------
|
|
185
|
+
// Precedence: the call's own `model` > the user's configured subagent model
|
|
186
|
+
// (/swarm-sub-agent) > the session's model. 'primary' always means "this
|
|
187
|
+
// session's model", so an explicit primary beats a configured default.
|
|
188
|
+
let subCfg = ctx;
|
|
189
|
+
const wantPrimary = args.model === 'primary';
|
|
190
|
+
const explicit = args.model && !wantPrimary && ctx.raw && ctx.raw.models && ctx.raw.models[args.model]
|
|
191
|
+
? args.model : '';
|
|
192
|
+
const configured = ctx.subagentModel && ctx.raw && ctx.raw.models && ctx.raw.models[ctx.subagentModel]
|
|
193
|
+
? ctx.subagentModel : '';
|
|
194
|
+
const chosen = wantPrimary ? '' : (explicit || configured);
|
|
195
|
+
if (chosen) {
|
|
196
|
+
const { resolveModelArg } = await import('../config.js');
|
|
197
|
+
subCfg = resolveModelArg(ctx, chosen);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- fork: snapshot this conversation --------------------------------
|
|
201
|
+
let history = null;
|
|
202
|
+
if (wantFork) {
|
|
203
|
+
history = (ctx._parentMessages || []).filter(
|
|
204
|
+
(m) => m.role !== 'system' && m.role !== 'tool' && typeof m.content === 'string' && m.content.trim(),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- background -------------------------------------------------------
|
|
209
|
+
if (args.run_in_background === true) {
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const task = createTask(ctx, 'agent', {
|
|
212
|
+
description,
|
|
213
|
+
agentId: null,
|
|
214
|
+
subagentType: type,
|
|
215
|
+
model: args.model || '',
|
|
216
|
+
detached: true,
|
|
217
|
+
});
|
|
218
|
+
task._abort = () => controller.abort();
|
|
219
|
+
const agentId = existing ? existing.id : newAgentId();
|
|
220
|
+
task.agentId = agentId;
|
|
221
|
+
|
|
222
|
+
// Fire and forget: the task record carries the result when it lands.
|
|
223
|
+
void (async () => {
|
|
224
|
+
try {
|
|
225
|
+
const run = await runSubagent({
|
|
226
|
+
cfg: subCfg, type, prompt, history,
|
|
227
|
+
signal: controller.signal,
|
|
228
|
+
// No live streaming into the task output: that should read as the
|
|
229
|
+
// subagent's CONCLUSION. `run.text` below is the cleaned final message.
|
|
230
|
+
});
|
|
231
|
+
const entry = existing || { id: agentId, type, messages: [] };
|
|
232
|
+
entry.messages = run.messages;
|
|
233
|
+
entry.type = type;
|
|
234
|
+
subagents.set(agentId, entry);
|
|
235
|
+
appendTaskOutput(task, `\n\n${run.text}`);
|
|
236
|
+
settleTask(task, 'completed');
|
|
237
|
+
if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
const stopped = controller.signal.aborted;
|
|
240
|
+
settleTask(task, stopped ? 'killed' : 'failed', { stopReason: stopped ? 'Stopped by user' : e.message });
|
|
241
|
+
appendTaskOutput(task, `\n[error: ${e.message}]`);
|
|
242
|
+
if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
|
|
243
|
+
}
|
|
244
|
+
})();
|
|
245
|
+
|
|
246
|
+
return `agent_id: ${agentId}\ntask_id: ${task.taskId}\nstatus: running\nnext_step: Continue your work; the result arrives in a later message. Use TaskOutput {task_id: "${task.taskId}"} for a non-blocking check or TaskStop to cancel.`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- foreground (the default; Ctrl+B detaches it) ---------------------
|
|
250
|
+
// Registered like Bash's foreground hook so the TUI's ctrl+b can move a long
|
|
251
|
+
// subagent into the background WITHOUT losing its work: the run keeps going,
|
|
252
|
+
// the task record gets the result when it lands, and this call resolves at
|
|
253
|
+
// once with the task id so the parent turn can continue.
|
|
254
|
+
const bgController = new AbortController();
|
|
255
|
+
const onParentAbort = () => bgController.abort();
|
|
256
|
+
if (ctx.signal) {
|
|
257
|
+
if (ctx.signal.aborted) return 'Error: the turn was interrupted before the subagent started.';
|
|
258
|
+
ctx.signal.addEventListener('abort', onParentAbort, { once: true });
|
|
259
|
+
}
|
|
260
|
+
const agentId = existing ? existing.id : newAgentId();
|
|
261
|
+
const entry = existing || { id: agentId, type, messages: [] };
|
|
262
|
+
subagents.set(agentId, entry);
|
|
263
|
+
|
|
264
|
+
let detachedId = null;
|
|
265
|
+
const resetForeground = () => { if (ctx && ctx._foreground && ctx._foreground.detach) ctx._foreground = null; };
|
|
266
|
+
// Start the run BEFORE the detach hook is installed, so the hook's closure can
|
|
267
|
+
// reference the in-flight promise (it waits on it to record the result).
|
|
268
|
+
// `onProgress` feeds the parent's live-output sink (ctx.onOutput), which the
|
|
269
|
+
// agent tags with THIS tool call's id — the same path Bash uses, so the TUI
|
|
270
|
+
// renders the subagent's stream under the `Using Agent` row and Ctrl+O folds it.
|
|
271
|
+
const runPromise = runSubagent({
|
|
272
|
+
cfg: subCfg, type, prompt, history, signal: bgController.signal,
|
|
273
|
+
// `_progressTag` is set by AgentSwarm to the item index, so a swarm's
|
|
274
|
+
// concurrently-running subagents are distinguishable in the live stream.
|
|
275
|
+
// A plain Agent call has no tag and prints its stream unlabelled.
|
|
276
|
+
onProgress: (text) => {
|
|
277
|
+
if (typeof ctx.onOutput !== 'function') return;
|
|
278
|
+
const tag = ctx._progressTag ? `[${ctx._progressTag}] ` : '';
|
|
279
|
+
ctx.onOutput(tag ? text.split('\n').map((l) => (l ? tag + l : l)).join('\n') : text);
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
if (ctx) {
|
|
283
|
+
ctx._foreground = {
|
|
284
|
+
kind: 'Agent',
|
|
285
|
+
description,
|
|
286
|
+
detach: () => {
|
|
287
|
+
if (detachedId) return detachedId;
|
|
288
|
+
// Hand the in-flight work to a background task. The controller is NOT
|
|
289
|
+
// aborted, so the subagent keeps running.
|
|
290
|
+
const task = createTask(ctx, 'agent', {
|
|
291
|
+
description, agentId, subagentType: type, model: args.model || '', detached: true,
|
|
292
|
+
});
|
|
293
|
+
task._abort = () => bgController.abort();
|
|
294
|
+
detachedId = task.taskId;
|
|
295
|
+
resetForeground();
|
|
296
|
+
void (async () => {
|
|
297
|
+
try {
|
|
298
|
+
const r = await runPromise;
|
|
299
|
+
entry.messages = r.messages; entry.type = type;
|
|
300
|
+
subagents.set(agentId, entry);
|
|
301
|
+
appendTaskOutput(task, r.text);
|
|
302
|
+
settleTask(task, 'completed');
|
|
303
|
+
if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
const stopped = bgController.signal.aborted;
|
|
306
|
+
settleTask(task, stopped ? 'killed' : 'failed', { stopReason: stopped ? 'Stopped by user' : e.message });
|
|
307
|
+
appendTaskOutput(task, `\n[error: ${e.message}]`);
|
|
308
|
+
if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
311
|
+
return task.taskId;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
let run;
|
|
316
|
+
try {
|
|
317
|
+
run = await runPromise;
|
|
318
|
+
} catch (e) {
|
|
319
|
+
resetForeground();
|
|
320
|
+
// Detached: the background task owns the error now, not this call.
|
|
321
|
+
if (detachedId) return `Moved to background as task #${detachedId}. It keeps running; inspect with TaskList / TaskOutput {task_id: "${detachedId}"}.`;
|
|
322
|
+
return `Error: subagent failed: ${e.message}`;
|
|
323
|
+
}
|
|
324
|
+
if (ctx.signal) ctx.signal.removeEventListener('abort', onParentAbort);
|
|
325
|
+
resetForeground();
|
|
326
|
+
if (detachedId) {
|
|
327
|
+
return `Moved to background as task #${detachedId}. It keeps running; inspect with TaskList / TaskOutput {task_id: "${detachedId}"}.`;
|
|
328
|
+
}
|
|
329
|
+
entry.messages = run.messages;
|
|
330
|
+
entry.type = type;
|
|
331
|
+
subagents.set(agentId, entry);
|
|
332
|
+
|
|
333
|
+
return `agent_id: ${agentId}\n\n${run.text}`;
|
|
334
|
+
},
|
|
335
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// AskUserQuestion tool — ask the user 1-4 structured multiple-choice questions
|
|
2
|
+
// mid-turn and block until they answer. Mirrors kimi-code's AskUserQuestion:
|
|
3
|
+
// the same input shape, the same duplicate validation, and the same result
|
|
4
|
+
// JSON, so a model trained on either behaves the same.
|
|
5
|
+
//
|
|
6
|
+
// The TUI supplies the actual UI through `ctx.askQuestion` (a function injected
|
|
7
|
+
// by the Agent). When it is absent — headless `-p` mode, a plugin harness, or a
|
|
8
|
+
// client that cannot prompt — the tool FAILS with a message telling the model to
|
|
9
|
+
// ask in plain text instead of silently returning an empty answer, which would
|
|
10
|
+
// look like "the user declined".
|
|
11
|
+
|
|
12
|
+
const MAX_QUESTIONS = 4;
|
|
13
|
+
const MIN_OPTIONS = 2;
|
|
14
|
+
const MAX_OPTIONS = 4;
|
|
15
|
+
|
|
16
|
+
// The user always gets an escape hatch for free input, so the model must not
|
|
17
|
+
// waste one of its 2-4 option slots on an "Other" of its own.
|
|
18
|
+
export const OTHER_LABEL = 'Other';
|
|
19
|
+
|
|
20
|
+
// A SECOND free-text row, shown once after the LAST question: not an answer to
|
|
21
|
+
// anything, just somewhere to add context that no option covered. Reported as
|
|
22
|
+
// `additional` on the result (NOT `note`, which already means "dismissed").
|
|
23
|
+
export const SUPPLEMENT_LABEL = 'Add a note';
|
|
24
|
+
|
|
25
|
+
const UNIQUENESS_HINT =
|
|
26
|
+
'Question texts must be unique across questions, and option labels must be unique within each question.';
|
|
27
|
+
|
|
28
|
+
// Validate the two uniqueness rules kimi enforces. Returns an error string, or
|
|
29
|
+
// null when the input is acceptable. Checks run BEFORE any UI is shown so a
|
|
30
|
+
// malformed call never interrupts the user.
|
|
31
|
+
export function questionUniquenessError(questions) {
|
|
32
|
+
const texts = new Set();
|
|
33
|
+
for (const q of questions) {
|
|
34
|
+
if (texts.has(q.question)) {
|
|
35
|
+
return `Invalid questions: duplicate question text ${JSON.stringify(q.question)}. ${UNIQUENESS_HINT} Rephrase the duplicates and call the tool again.`;
|
|
36
|
+
}
|
|
37
|
+
texts.add(q.question);
|
|
38
|
+
const labels = new Set();
|
|
39
|
+
for (const o of q.options) {
|
|
40
|
+
if (labels.has(o.label)) {
|
|
41
|
+
return `Invalid questions: duplicate option label ${JSON.stringify(o.label)} in question ${JSON.stringify(q.question)}. ${UNIQUENESS_HINT} Rephrase the duplicates and call the tool again.`;
|
|
42
|
+
}
|
|
43
|
+
labels.add(o.label);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DESCRIPTION = `Use this tool when you need to ask the user questions with structured options during execution. This allows you to:
|
|
50
|
+
1. Collect user preferences or requirements before proceeding
|
|
51
|
+
2. Resolve ambiguous or underspecified instructions
|
|
52
|
+
3. Let the user decide between implementation approaches as you work
|
|
53
|
+
4. Present concrete options when multiple valid directions exist
|
|
54
|
+
|
|
55
|
+
When NOT to use:
|
|
56
|
+
- When you can infer the answer from context — be decisive and proceed
|
|
57
|
+
- Trivial decisions that don't materially affect the outcome
|
|
58
|
+
|
|
59
|
+
Overusing this tool interrupts the user's flow. Only use it when the user's input genuinely changes your next action.
|
|
60
|
+
|
|
61
|
+
Usage notes:
|
|
62
|
+
- Users always have an "Other" option for custom input — don't create one yourself
|
|
63
|
+
- Use multi_select to allow multiple answers to be selected for a question
|
|
64
|
+
- Keep option labels concise (1-5 words), use descriptions for trade-offs and details
|
|
65
|
+
- Each question should have 2-4 meaningful, distinct options
|
|
66
|
+
- Question texts must be unique across the call, and option labels must be unique within each question
|
|
67
|
+
- You can ask 1-4 questions at a time; group related questions to minimize interruptions
|
|
68
|
+
- If you recommend a specific option, list it first and append "(Recommended)" to its label
|
|
69
|
+
- The result is JSON with an \`answers\` object keyed by question text; each value is the chosen option's label (comma-separated labels for multi_select, or the user's own words if they picked "Other"); if \`answers\` is empty and a \`note\` says the user dismissed it, they chose not to answer — do not treat this as selecting the recommended option; decide based on context and do not re-ask the same question
|
|
70
|
+
- The user may also leave a free-form \`additional\` note covering the whole request; the field appears only when they wrote one. Treat it as extra context, not as the answer to a specific question`;
|
|
71
|
+
|
|
72
|
+
export const spec = {
|
|
73
|
+
name: 'AskUserQuestion',
|
|
74
|
+
description: DESCRIPTION,
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
questions: {
|
|
79
|
+
type: 'array',
|
|
80
|
+
minItems: 1,
|
|
81
|
+
maxItems: MAX_QUESTIONS,
|
|
82
|
+
description: 'The questions to ask the user (1-4 questions).',
|
|
83
|
+
items: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
properties: {
|
|
86
|
+
question: {
|
|
87
|
+
type: 'string',
|
|
88
|
+
description: "A specific, actionable question. End with '?'.",
|
|
89
|
+
},
|
|
90
|
+
header: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description: "Short category tag (max 12 chars, e.g. 'Auth', 'Style').",
|
|
93
|
+
},
|
|
94
|
+
options: {
|
|
95
|
+
type: 'array',
|
|
96
|
+
minItems: MIN_OPTIONS,
|
|
97
|
+
maxItems: MAX_OPTIONS,
|
|
98
|
+
description:
|
|
99
|
+
"2-4 meaningful, distinct options. Do NOT include an 'Other' option — the system adds one automatically.",
|
|
100
|
+
items: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
label: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
description: "Concise display text (1-5 words). If recommended, append '(Recommended)'.",
|
|
106
|
+
},
|
|
107
|
+
description: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: 'Brief explanation of trade-offs or implications.',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
required: ['label'],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
multi_select: {
|
|
116
|
+
type: 'boolean',
|
|
117
|
+
description: 'Whether the user can select multiple options.',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
required: ['question', 'options'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
required: ['questions'],
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async execute(args, ctx) {
|
|
128
|
+
// AUTO ("Never Ask") forbids asking by definition — the whole point of the
|
|
129
|
+
// mode is that the agent decides for itself. Checked FIRST so the model gets
|
|
130
|
+
// a clear instruction back instead of a dialog the user never wanted. The
|
|
131
|
+
// wording mirrors kimi-code's `auto-mode-ask-user-question-deny` policy; the
|
|
132
|
+
// `Error:` prefix is ours — it is what marks the call as failed to the TUI, so
|
|
133
|
+
// the status bullet turns RED instead of staying green on a refusal.
|
|
134
|
+
if (ctx && ctx.permissionMode === 'auto') {
|
|
135
|
+
return 'Error: AskUserQuestion is disabled while auto permission mode is active. Make a reasonable decision and continue without asking the user.';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const raw = (args && args.questions) || [];
|
|
139
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
140
|
+
return 'Error: `questions` must be a non-empty array (1-4 questions).';
|
|
141
|
+
}
|
|
142
|
+
if (raw.length > MAX_QUESTIONS) {
|
|
143
|
+
return `Error: at most ${MAX_QUESTIONS} questions per call (got ${raw.length}). Group related questions to minimize interruptions.`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Normalize + validate BEFORE showing any UI: a malformed call must not
|
|
147
|
+
// interrupt the user, it must come straight back to the model.
|
|
148
|
+
const questions = [];
|
|
149
|
+
for (const q of raw) {
|
|
150
|
+
const text = String((q && q.question) || '').trim();
|
|
151
|
+
if (!text) return 'Error: every question needs a non-empty `question` string.';
|
|
152
|
+
const opts = Array.isArray(q && q.options) ? q.options : [];
|
|
153
|
+
if (opts.length < MIN_OPTIONS || opts.length > MAX_OPTIONS) {
|
|
154
|
+
return `Error: question ${JSON.stringify(text)} needs ${MIN_OPTIONS}-${MAX_OPTIONS} options (got ${opts.length}).`;
|
|
155
|
+
}
|
|
156
|
+
const options = [];
|
|
157
|
+
for (const o of opts) {
|
|
158
|
+
const label = String((o && o.label) || '').trim();
|
|
159
|
+
if (!label) return `Error: every option in ${JSON.stringify(text)} needs a non-empty \`label\`.`;
|
|
160
|
+
options.push({ label, description: String((o && o.description) || '') });
|
|
161
|
+
}
|
|
162
|
+
questions.push({
|
|
163
|
+
question: text,
|
|
164
|
+
header: String((q && q.header) || '').trim().slice(0, 12),
|
|
165
|
+
options,
|
|
166
|
+
multiSelect: !!(q && q.multi_select),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const dup = questionUniquenessError(questions);
|
|
171
|
+
if (dup) return dup;
|
|
172
|
+
|
|
173
|
+
// No interactive client: say so explicitly. Returning `{answers:{}}` would be
|
|
174
|
+
// read as "the user refused to answer", which is a different signal.
|
|
175
|
+
if (typeof ctx.askQuestion !== 'function') {
|
|
176
|
+
return 'The connected client does not support interactive questions. Do NOT call this tool again. Ask the user directly in your text response instead.';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let result;
|
|
180
|
+
try {
|
|
181
|
+
result = await ctx.askQuestion(questions, ctx);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// An aborted turn (Esc / Ctrl-C) is not a dismissal — let the agent see the
|
|
184
|
+
// interruption instead of a fabricated empty answer.
|
|
185
|
+
if (ctx.signal && ctx.signal.aborted) throw err;
|
|
186
|
+
return `Error: could not ask the user: ${(err && err.message) || String(err)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Dismissed (Esc) or nothing answered: kimi's exact semantics — an empty
|
|
190
|
+
// answers object plus a note telling the model NOT to treat it as consent.
|
|
191
|
+
// A dismissal also drops any note the user had started typing.
|
|
192
|
+
const answers = (result && typeof result === 'object' && result.answers) || null;
|
|
193
|
+
const additional = (result && typeof result === 'object' && result.additional) || '';
|
|
194
|
+
if (!answers || Object.keys(answers).length === 0) {
|
|
195
|
+
return JSON.stringify({
|
|
196
|
+
answers: {},
|
|
197
|
+
note: 'User dismissed the question without answering.',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// `additional` is OPTIONAL: omit the key entirely when the user left the
|
|
201
|
+
// supplement blank, so the model does not see a meaningless empty field.
|
|
202
|
+
const out = { answers };
|
|
203
|
+
if (additional) out.additional = additional;
|
|
204
|
+
return JSON.stringify(out);
|
|
205
|
+
},
|
|
206
|
+
};
|
package/src/tools/bash.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import cp from 'node:child_process';
|
|
8
8
|
import { resolvePath, truncateBuf } from './utils.js';
|
|
9
|
+
import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
|
|
9
10
|
|
|
10
11
|
// kimi-code's timeout policy (agent-core-v2/agent/tools/os/bash/bash.ts).
|
|
11
12
|
export const DEFAULT_TIMEOUT_S = 60;
|
|
@@ -13,6 +14,8 @@ export const MAX_TIMEOUT_S = 5 * 60;
|
|
|
13
14
|
export const DEFAULT_BACKGROUND_TIMEOUT_S = 10 * 60;
|
|
14
15
|
export const MAX_BACKGROUND_TIMEOUT_S = 24 * 60 * 60;
|
|
15
16
|
|
|
17
|
+
|
|
18
|
+
|
|
16
19
|
// ---- output sanitizing -------------------------------------------------------
|
|
17
20
|
// Captured output can contain terminal control sequences (colours, cursor
|
|
18
21
|
// moves, `\r` spinners, bells, …). hncode's renderer passes result strings to
|
|
@@ -190,51 +193,66 @@ export const spec = {
|
|
|
190
193
|
// `existingChunks` array the task reads, so we must NOT add new listeners here
|
|
191
194
|
// (doing so doubled every line of output).
|
|
192
195
|
function registerBackgroundTask(args, child, ctx, existingChunks) {
|
|
193
|
-
const
|
|
194
|
-
const task = {
|
|
195
|
-
id, pid: child.pid, command: args.command,
|
|
196
|
+
const task = createTask(ctx, 'process', {
|
|
196
197
|
description: args.description || args.command,
|
|
197
|
-
|
|
198
|
+
command: args.command,
|
|
199
|
+
pid: child.pid,
|
|
200
|
+
detached: true,
|
|
198
201
|
_chunks: existingChunks || [],
|
|
199
|
-
};
|
|
200
|
-
Object.defineProperty(task, 'output', {
|
|
201
|
-
enumerable: true,
|
|
202
|
-
get() { return sanitizeShellOutput(decodeChunks(this._chunks)); },
|
|
203
202
|
});
|
|
204
|
-
|
|
205
|
-
child.on('close', (code) => {
|
|
206
|
-
|
|
203
|
+
defineOutput(task);
|
|
204
|
+
child.on('close', (code) => {
|
|
205
|
+
// A task already settled (TaskStop -> 'killed') keeps its status; the
|
|
206
|
+
// process's non-zero kill exit must not relabel a deliberate stop.
|
|
207
|
+
if (task.status === 'running') {
|
|
208
|
+
settleTask(task, code === 0 ? 'completed' : 'failed', { exitCode: code });
|
|
209
|
+
} else {
|
|
210
|
+
task.exitCode = code;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
return task.taskId;
|
|
207
214
|
}
|
|
208
215
|
|
|
209
216
|
function spawnBackground(args, cwd, ctx) {
|
|
210
217
|
const command = args.command;
|
|
211
218
|
const child = cp.spawn('pwsh', ['-Command', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
212
|
-
const
|
|
213
|
-
const task = {
|
|
214
|
-
id, pid: child.pid, command: args.command,
|
|
219
|
+
const task = createTask(ctx, 'process', {
|
|
215
220
|
description: args.description || args.command,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// Decode lazily: the accumulated bytes are decoded only when the output is
|
|
220
|
-
// actually read, so a chatty background task costs nothing until queried.
|
|
221
|
-
Object.defineProperty(task, 'output', {
|
|
222
|
-
enumerable: true,
|
|
223
|
-
get() { return sanitizeShellOutput(decodeChunks(this._chunks)); },
|
|
221
|
+
command: args.command,
|
|
222
|
+
pid: child.pid,
|
|
223
|
+
detached: true,
|
|
224
224
|
});
|
|
225
|
-
|
|
226
|
-
const push = (d) =>
|
|
225
|
+
defineOutput(task);
|
|
226
|
+
const push = (d) => appendTaskOutput(task, d);
|
|
227
227
|
if (child.stdout) child.stdout.on('data', push);
|
|
228
228
|
if (child.stderr) child.stderr.on('data', push);
|
|
229
|
-
child.on('close', (code) => {
|
|
229
|
+
child.on('close', (code) => {
|
|
230
|
+
if (task.status === 'running') {
|
|
231
|
+
settleTask(task, code === 0 ? 'completed' : 'failed', { exitCode: code });
|
|
232
|
+
} else {
|
|
233
|
+
task.exitCode = code;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
230
236
|
// Background timeout: default 600s, capped at 86400s; `disable_timeout` removes
|
|
231
237
|
// it. The timer is unref'd so it never keeps the process alive on its own.
|
|
232
238
|
if (!args.disable_timeout) {
|
|
233
239
|
const bgTimeoutMs = Math.min(args.timeout ?? DEFAULT_BACKGROUND_TIMEOUT_S, MAX_BACKGROUND_TIMEOUT_S) * 1000;
|
|
234
|
-
const t = setTimeout(() => {
|
|
240
|
+
const t = setTimeout(() => {
|
|
241
|
+
killTree(child);
|
|
242
|
+
settleTask(task, 'timed_out', { stopReason: 'timed out' });
|
|
243
|
+
}, bgTimeoutMs);
|
|
235
244
|
if (t.unref) t.unref();
|
|
236
245
|
}
|
|
237
246
|
child.unref();
|
|
238
|
-
return `Background task
|
|
239
|
-
Inspect with TaskList (running tasks) and TaskOutput {task_id: "${
|
|
247
|
+
return `Background task ${task.taskId} started (pid ${child.pid}): ${args.description || args.command}
|
|
248
|
+
Inspect with TaskList (running tasks) and TaskOutput {task_id: "${task.taskId}"} (output).`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Lazy output: the accumulated bytes are decoded only when actually read, so a
|
|
252
|
+
// chatty background task costs nothing until queried.
|
|
253
|
+
function defineOutput(task) {
|
|
254
|
+
Object.defineProperty(task, 'output', {
|
|
255
|
+
enumerable: true,
|
|
256
|
+
get() { return sanitizeShellOutput(decodeChunks(this._chunks)); },
|
|
257
|
+
});
|
|
240
258
|
}
|