@hncode/hncode 0.1.11 → 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.
@@ -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 id = `task_${Date.now()}`;
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
- status: 'running', start: Date.now(),
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
- ctx.tasks[id] = task;
205
- child.on('close', (code) => { task.status = code === 0 ? 'done' : 'failed'; task.code = code; task.end = Date.now(); });
206
- return id;
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 id = `task_${Date.now()}`;
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
- status: 'running', start: Date.now(),
217
- _chunks: [],
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
- ctx.tasks[id] = task;
226
- const push = (d) => { task._chunks.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) => { task.status = code === 0 ? 'done' : 'failed'; task.code = code; task.end = Date.now(); });
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(() => { killTree(child); task.status = 'failed'; task.stopReason = 'timed out'; }, bgTimeoutMs);
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 #${id} started (pid ${child.pid}): ${args.description || args.command}
239
- Inspect with TaskList (running tasks) and TaskOutput {task_id: "${id}"} (output).`;
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
  }