@hncode/hncode 0.5.1 → 0.5.2

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.
@@ -18,9 +18,25 @@ import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
18
18
 
19
19
  export const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}';
20
20
  export const MAX_AGENT_SWARM_SUBAGENTS = 128;
21
- // How many subagents run at once. kimi queues launches automatically; we use a
22
- // small fixed window so a 128-item swarm cannot stampede the machine.
23
- const SWARM_CONCURRENCY = 4;
21
+ // How many subagents run at once. Each worker awaits ONE subagent at a time, so this is the
22
+ // number of models generating concurrently — the reason a swarm exists at all, given that
23
+ // `Agent` calls in one message are run in sequence.
24
+ const SWARM_CONCURRENCY = 7;
25
+
26
+ /**
27
+ * Median of a list of numbers, or 0 when there is nothing to go on.
28
+ *
29
+ * Median rather than mean: one agent that loops for a hundred steps would drag a mean far
30
+ * above what a typical agent costs, and every other bar would then sit near empty while its
31
+ * agent was in fact nearly done.
32
+ */
33
+ function medianSteps(counts) {
34
+ if (!Array.isArray(counts) || !counts.length) return 0;
35
+ const sorted = counts.slice().sort((a, b) => a - b);
36
+ const mid = Math.floor(sorted.length / 2);
37
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
38
+ }
39
+
24
40
 
25
41
  // Validate and expand the swarm request. Returns { prompts } or { error }.
26
42
  export function planSwarm(args) {
@@ -35,6 +51,22 @@ export function planSwarm(args) {
35
51
  ? Object.entries(args.resume_agent_ids).map(([id, p]) => [String(id).trim(), String(p).trim()])
36
52
  : [];
37
53
 
54
+ // The SHAPE is checked before the counts, so the message names the field that is wrong. An
55
+ // array of ids used to be silently dropped, and the caller was then told to provide "a
56
+ // resume_agent_ids map" — which it had given, in the only shape it knew.
57
+ if (args.resume_agent_ids !== undefined
58
+ && (typeof args.resume_agent_ids !== 'object' || Array.isArray(args.resume_agent_ids))) {
59
+ return { error: 'Error: `resume_agent_ids` must be an object mapping agent_id to prompt, such as {"<agent_id>": "continue"}. An array of ids is not accepted.' };
60
+ }
61
+ if (args.items !== undefined && !Array.isArray(args.items)) {
62
+ return { error: 'Error: `items` must be an array of strings.' };
63
+ }
64
+ // Blank entries were dropped above. Two blank items is a request the caller THINKS it made, and
65
+ // "provide at least 2 items" reads as a refusal of something that was provided.
66
+ const rawCount = Array.isArray(args.items) ? args.items.length : 0;
67
+ if (rawCount > 0 && items.length < rawCount && items.length + resumeMap.length < 2) {
68
+ return { error: `Error: ${rawCount} item(s) given but ${rawCount - items.length} were blank, leaving ${items.length}. Each item must be a non-empty string.` };
69
+ }
38
70
  if (items.length === 0 && resumeMap.length === 0) {
39
71
  return { error: 'Error: provide at least 2 items, or a resume_agent_ids map.' };
40
72
  }
@@ -73,7 +105,7 @@ Use enough subagents to keep the work focused and parallel. AgentSwarm supports
73
105
  When NOT to use AgentSwarm:
74
106
  - It only exists while swarm mode is ON (\`/swarm\`). With the mode off the tool is not offered at all and calling it is refused. If a fan-out would genuinely help, say so and ask the user to turn the mode on.
75
107
  - Never call this on your own initiative. It requires the same explicit permission as \`Agent\`: the user, or the applicable AGENTS.md / project instructions, must have asked for sub-agents, delegation, or parallel agent work. Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do NOT count as permission.
76
- - Do not use it for two or three differently-shaped tasks: use separate \`Agent\` calls instead, which can run in parallel in one message.
108
+ - Do not use it for two or three differently-shaped tasks: use separate \`Agent\` calls instead. Note that several \`Agent\` calls in one message run ONE AT A TIME — only read-only tools batch — so a swarm is the only way to overlap subagents, and a 3-item swarm really does run 3 at once.
77
109
  - Do not use it to fan out work you could finish yourself in a few steps.
78
110
 
79
111
  If \`AgentSwarm\` is called, that call must be the only tool call in the response.`,
@@ -122,45 +154,116 @@ If \`AgentSwarm\` is called, that call must be the only tool call in the respons
122
154
  detached: false,
123
155
  });
124
156
 
157
+ // ONE TASK PER SUBAGENT, parented to this one. A foreground subagent creates no task of
158
+ // its own, so a 7-item swarm put a single row in /tasks and there was no way to see what
159
+ // each subagent was doing — the panel is organised by task, so no task meant no window.
160
+ const subTasks = jobs.map((job, i) => {
161
+ const t = createTask(ctx, 'agent', {
162
+ description: `${description} #${i + 1}`,
163
+ subagentType: type,
164
+ model: args.model || '',
165
+ detached: true,
166
+ parentTaskId: task.taskId,
167
+ });
168
+ return t;
169
+ });
170
+
125
171
  // Run with a bounded window. Each job appends its verdict to the task output
126
172
  // as it finishes, so TaskOutput shows partial progress.
127
173
  const results = new Array(jobs.length).fill(null);
174
+ // Tool-call counts of the agents that have FINISHED, so a running agent's bar can be
175
+ // scaled against what the swarm is actually costing. A running agent does not contribute
176
+ // here — its own count is still growing and would understate the reference.
177
+ const finishedSteps = [];
128
178
  let next = 0;
129
179
  const worker = async () => {
130
180
  for (;;) {
131
181
  const i = next++;
132
182
  if (i >= jobs.length) return;
133
183
  const job = jobs[i];
184
+ const subTask = subTasks[i];
185
+ // Announce the START, not only the finish. Without this the only signal on the wire
186
+ // was "[n/m] finished", which says how many are DONE and nothing about which are
187
+ // running — so the progress block marked everything unfinished as "queued" and a
188
+ // 7-wide swarm looked like one working cell and six stuck ones.
189
+ if (typeof ctx.onOutput === 'function') ctx.onOutput(`[#${i + 1}] started\n`);
190
+ appendTaskOutput(task, `[#${i + 1}] started\n`);
191
+ // Declared OUTSIDE the try: the catch below still has to report this agent's cost,
192
+ // and the block that does so runs whether the agent succeeded or threw.
193
+ const t0 = Date.now();
194
+ let steps = 0;
134
195
  try {
135
196
  const subArgs = {
136
197
  prompt: job.prompt,
137
198
  description: `${description} #${i + 1}`,
138
199
  ...(job.kind === 'resume' ? { resume: job.agentId } : { subagent_type: type }),
200
+ ...(job.kind === 'resume' ? { resume: job.agentId } : { subagent_type: type }),
139
201
  ...(args.model ? { model: args.model } : {}),
140
202
  ...(args.fork === true && job.kind === 'new' ? { fork: true } : {}),
141
203
  };
142
- const out = await agentSpec.execute(subArgs, { ...ctx, _progressTag: `#${i + 1}` });
204
+ // `steps` counts this agent's tool calls so far. There is no "x of y" to show — a
205
+ // subagent runs with an unbounded step budget — so the bar is paced against the
206
+ // swarm's OWN observed speed: the median number of steps the agents that have
207
+ // already FINISHED needed. That is the only estimate available, and it sharpens as
208
+ // more of them land.
209
+ //
210
+ // The reference is never allowed to be 0. An agent that answers straight from the
211
+ // prompt runs no tools at all, so it finishes with 0 steps — and a median of 0 would
212
+ // send EVERY other agent down the elapsed-time branch below, where the bar needs a
213
+ // full minute to reach 40% and a fast agent reports 0% the whole way. One tool-less
214
+ // agent must not disable the scale for the rest of the swarm.
215
+ const progressLine = () => {
216
+ if (typeof ctx.onOutput !== 'function') return;
217
+ const reference = Math.max(1, medianSteps(finishedSteps));
218
+ const ratio = finishedSteps.length
219
+ ? Math.min(0.95, steps / (reference + 1))
220
+ // Nothing has finished yet, so there is no pace to compare against. Creep
221
+ // rather than assert a figure: `steps` alone would read as a tiny percentage
222
+ // next to an unknown total.
223
+ : Math.min(0.4, steps / 40, 0.1 + ((Date.now() - t0) / 60000) * 0.3);
224
+ ctx.onOutput(`[#${i + 1}] progress ${Math.round(ratio * 100)}\n`);
225
+ };
226
+ const out = await agentSpec.execute(subArgs, {
227
+ ...ctx,
228
+ _progressTag: `#${i + 1}`,
229
+ _subTaskId: subTask.taskId,
230
+ _onSubProgress: () => { steps++; progressLine(); },
231
+ });
143
232
  // `agentSpec.execute` returns `agent_id: <id>\n\n<handoff>`. The swarm's
144
233
  // caller wants the handoff, and the id is only useful for a later
145
234
  // `resume`; keep it on one labelled line rather than letting it lead the
146
235
  // block, so the reported text is what each subagent actually produced.
147
236
  const raw = String(out);
148
237
  const m = /^agent_id:\s*(\S+)\s*\n+([\s\S]*)$/.exec(raw);
238
+ // A subagent that died returns `Error: subagent … failed: …` rather than throwing, so
239
+ // the catch below never sees it. Without this test the cell was marked a success and
240
+ // the failure text was buried in a body nobody reads as an error.
241
+ const died = /^Error: subagent /.test(raw);
149
242
  const body = (m ? m[2] : raw).trim();
150
243
  const head = `--- #${i + 1}${job.kind === 'resume' ? ` (resumed ${job.agentId})` : ''}${m ? ` [${m[1]}]` : ''} ---`;
151
244
  results[i] = `${head}\n${body}`;
245
+ settleTask(subTask, died ? 'failed' : 'completed', died ? { stopReason: body } : undefined);
152
246
  } catch (e) {
153
247
  results[i] = `--- #${i + 1} ---\n[error: ${e.message}]`;
248
+ settleTask(subTask, 'failed', { stopReason: e.message });
154
249
  }
250
+ // Contribute this agent's cost to the reference the running ones scale against. A
251
+ // Contribute this agent's cost to the reference the running ones scale against. A
252
+ // failed agent is recorded too: it did that much work, so it says as much about the
253
+ // pace as a successful one, and excluding it would bias the bar optimistically.
254
+ finishedSteps.push(steps);
155
255
  // Report progress down BOTH channels, and they are not interchangeable:
156
256
  // * the task buffer feeds TaskOutput and survives a detach;
157
- // * `ctx.onOutput` is the LIVE sink. The progress block in the transcript
158
- // reads the cells off it (see the tool_output handler's `[n/m] finished`
159
- // match), so writing only to the task buffer left every cell but the first
160
- // stuck on "Queued…" for the whole run — the TUI was told to expect these
161
- // lines on this channel and never received one.
257
+ // * `ctx.onOutput` is the LIVE sink the transcript's progress block reads.
258
+ //
259
+ // WHICH agent finished, not just how many. A bare `[n/m]` count left the reader no
260
+ // choice but to assume the first `n` agents were the finished ones — and with
261
+ // SWARM_CONCURRENCY workers pulling from a shared queue, they finish in whatever order
262
+ // they happen to run. The grid then lit up the wrong cells and only agreed with
263
+ // reality once every one of them was done. `[#i] finished` names the agent; the count
264
+ // is kept for readers that only track totals.
162
265
  const done = results.filter((r) => r !== null).length;
163
- const line = `[${done}/${jobs.length}] finished\n`;
266
+ const line = `[#${i + 1}] finished [${done}/${jobs.length}]\n`;
164
267
  appendTaskOutput(task, line);
165
268
  if (typeof ctx.onOutput === 'function') ctx.onOutput(line);
166
269
  }
@@ -54,7 +54,7 @@ function systemPromptFor(type, cfg, systemPrompt) {
54
54
  // stream, and each tool it runs) so the TUI can show it under the `Using Agent`
55
55
  // row the way Bash's output is shown. `agentId` labels the lines when several
56
56
  // subagents run at once (AgentSwarm), so the reader can tell them apart.
57
- async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProgress, agentId, onAgent }) {
57
+ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProgress, onStep, agentId, onAgent }) {
58
58
  const { Agent, SYSTEM_PROMPT } = await import('../agent.js');
59
59
  const subCfg = {
60
60
  ...cfg,
@@ -72,6 +72,9 @@ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProg
72
72
  : [{ role: 'user', content: prompt }];
73
73
 
74
74
  let text = '';
75
+ // A failure used to leave `text` empty, so a caller could not tell "produced nothing" from
76
+ // "died". The Agent's only signal on error is an event, so capture it here.
77
+ let failure = null;
75
78
  const agent = new Agent({
76
79
  cfg: subCfg,
77
80
  messages,
@@ -87,12 +90,19 @@ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProg
87
90
  if (e.text) onProgress(tag + e.text);
88
91
  } else if (e.type === 'tool_start') {
89
92
  onProgress(`\n${tag}\u25b8 ${e.name}\n`);
93
+ // One more round of work done. A SUBAGENT has no fixed step budget (agent.js sets
94
+ // `stepLimit = Infinity` unless the caller bounds it), so "steps remaining" is not
95
+ // a real quantity and a percentage of it would be invented. Tool calls completed is
96
+ // the honest measure: it only ever grows, it is what the run is actually doing, and
97
+ // the swarm turns it into a bar against the pace its other agents have set.
98
+ if (typeof onStep === 'function') onStep();
90
99
  } else if (e.type === 'tool_result') {
91
100
  const body = String(e.content == null ? '' : e.content);
92
101
  const first = body.split('\n').slice(0, 3).join('\n');
93
102
  onProgress(`${tag}${first}${body.split('\n').length > 3 ? '\n\u2026' : ''}\n`);
94
103
  } else if (e.type === 'error') {
95
104
  onProgress(`\n${tag}[error] ${(e.error && e.error.message) || String(e.error)}\n`);
105
+ failure = (e.error && e.error.message) || String(e.error);
96
106
  }
97
107
  }
98
108
  if (onEvent) onEvent(e);
@@ -139,7 +149,10 @@ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProg
139
149
  const lastAssistant = [...agent.messages].reverse()
140
150
  .find((m) => m.role === 'assistant' && typeof m.content === 'string' && stripThink(m.content));
141
151
  const result = (lastAssistant && stripThink(lastAssistant.content)) || stripThink(text) || '(no output)';
142
- return { text: result, messages: agent.messages };
152
+ // The failure travels WITH the text. A run that died and a run that simply produced nothing
153
+ // both used to arrive as an empty/`(no output)` string, so the caller could not tell them apart
154
+ // and reported a dead agent as a successful empty answer.
155
+ return { text: result, messages: agent.messages, failed: failure };
143
156
  }
144
157
 
145
158
  export const spec = {
@@ -206,7 +219,7 @@ ${availableTypes()}`,
206
219
 
207
220
  // ---- model override ---------------------------------------------------
208
221
  // Precedence: the call's own `model` > the user's configured subagent model
209
- // (/swarm-sub-agent) > the session's model. 'primary' always means "this
222
+ // (/subagent-model) > the session's model. 'primary' always means "this
210
223
  // session's model", so an explicit primary beats a configured default.
211
224
  let subCfg = ctx;
212
225
  const wantPrimary = args.model === 'primary';
@@ -214,6 +227,26 @@ ${availableTypes()}`,
214
227
  ? args.model : '';
215
228
  const configured = ctx.subagentModel && ctx.raw && ctx.raw.models && ctx.raw.models[ctx.subagentModel]
216
229
  ? ctx.subagentModel : '';
230
+ // A NAME THAT DOES NOT EXIST IS AN ERROR, not a fallback. Resolving it to "" ran the
231
+ // subagent on the session's model while the caller believed it had chosen — a typo, or a
232
+ // model configured in another session, produced a silently different agent.
233
+ if (args.model && !wantPrimary && !explicit) {
234
+ const known = Object.keys((ctx.raw && ctx.raw.models) || {});
235
+ const list = known.length ? known.join(', ') : 'none';
236
+ return `Error: unknown model "${args.model}". Configured: ${list}. Use "primary" for the session model.`;
237
+ }
238
+
239
+ // The user's `/subagent-model` setting can go stale when a model is renamed or removed, and
240
+ // it used to fail the same silent way. Say so, through onOutput so it reaches the transcript.
241
+ if (ctx.subagentModel && !configured) {
242
+ const list = Object.keys((ctx.raw && ctx.raw.models) || {}).join(', ') || 'none';
243
+ try {
244
+ if (typeof ctx.onOutput === 'function') {
245
+ ctx.onOutput(`[subagent model] "${ctx.subagentModel}" is no longer configured, so this subagent runs on the session model. Re-pick it with /swarm-sub-agent. Available: ${list}\n`);
246
+ }
247
+ } catch { /* a view that cannot be written to is not a reason to fail the call */ }
248
+ }
249
+
217
250
  const chosen = wantPrimary ? '' : (explicit || configured);
218
251
  if (chosen) {
219
252
  const { resolveModelArg } = await import('../config.js');
@@ -316,11 +349,23 @@ ${availableTypes()}`,
316
349
  // `_progressTag` is set by AgentSwarm to the item index, so a swarm's
317
350
  // concurrently-running subagents are distinguishable in the live stream.
318
351
  // A plain Agent call has no tag and prints its stream unlabelled.
352
+ //
353
+ // `_subTaskId` is also set by AgentSwarm: it names the task THIS subagent owns, so its
354
+ // stream lands on its own row in /tasks instead of being folded into the swarm's single
355
+ // buffer. Without it a 7-item swarm had one task and no way to see what any of them was
356
+ // doing.
319
357
  onProgress: (text) => {
358
+ if (ctx._subTaskId) {
359
+ const own = ctx.tasks && ctx.tasks[ctx._subTaskId];
360
+ if (own) appendTaskOutput(own, text);
361
+ }
320
362
  if (typeof ctx.onOutput !== 'function') return;
321
363
  const tag = ctx._progressTag ? `[${ctx._progressTag}] ` : '';
322
364
  ctx.onOutput(tag ? text.split('\n').map((l) => (l ? tag + l : l)).join('\n') : text);
323
365
  },
366
+ // Each completed tool call is one unit of progress. Only AgentSwarm supplies the sink;
367
+ // a lone Agent call has no grid to fill and nothing to report to.
368
+ onStep: () => { if (typeof ctx._onSubProgress === 'function') ctx._onSubProgress(); },
324
369
  });
325
370
  // Registered here so a foreground subagent is reachable by AgentMessage / AgentInterrupt
326
371
  // too — that is precisely the case where a user wants to redirect one: it is the run
@@ -382,6 +427,11 @@ ${availableTypes()}`,
382
427
  entry.type = type;
383
428
  subagents.set(agentId, entry);
384
429
 
430
+ // A FAILED subagent returns an error, not an empty string. `run.text` is '' when the Agent
431
+ // died, which the swarm printed as "(no output)" — so four agents that had all failed on one
432
+ // error looked like four agents that had found nothing to say.
433
+ if (run.failed) return `Error: subagent "${agentId}" failed: ${run.failed}`;
434
+
385
435
  return `agent_id: ${agentId}\n\n${run.text}`;
386
436
  },
387
437
  };