@hncode/hncode 0.5.1 → 0.5.4
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/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +19 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/src/term.js
CHANGED
|
@@ -379,17 +379,91 @@ const tokCache = new Map();
|
|
|
379
379
|
const TOK_CACHE_MAX = 20000;
|
|
380
380
|
const TOK_CACHE_MAX_LEN = 4096; // only memoise strings this short
|
|
381
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The same estimate, UNROUNDED — for summing many small chunks.
|
|
384
|
+
*
|
|
385
|
+
* `estimateTokens` rounds up, which is right for measuring one finished body: a 5-character
|
|
386
|
+
* message should not read as 1.25 tokens.
|
|
387
|
+
*
|
|
388
|
+
* It is wrong for a STREAM. Deltas arrive a few characters at a time, and each one took
|
|
389
|
+
* `ceil(ascii / 4)` — `ceil(3/4)` is 1, so three characters were billed as a whole token. Over
|
|
390
|
+
* hundreds of chunks that inflated the running figure badly: measured at +33% on ordinary
|
|
391
|
+
* English, and the visible symptom was the output counter DROPPING (3000 -> 2300) the moment the
|
|
392
|
+
* provider's exact `usage` arrived and replaced the estimate. Nothing was lost; the estimate had
|
|
393
|
+
* been lying.
|
|
394
|
+
*
|
|
395
|
+
* So the running counter accumulates this unrounded value, and rounds once when it displays.
|
|
396
|
+
* Callers that measure a complete string keep using `estimateTokens`.
|
|
397
|
+
*/
|
|
398
|
+
export function estimateTokensFloat(text) {
|
|
399
|
+
return estimateBpe(String(text ?? ''));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A BPE-aware token estimate: characters per token, BY CHARACTER CLASS.
|
|
404
|
+
*
|
|
405
|
+
* The old rule was `ascii / 4 + nonAscii`, and it is wrong in a way that matters here. Measured against
|
|
406
|
+
* published BPE rates for this app's own content, it undercounted by up to 35%:
|
|
407
|
+
*
|
|
408
|
+
* English prose 1.05x (the rule was calibrated for exactly this)
|
|
409
|
+
* TypeScript code 0.80x
|
|
410
|
+
* JSON payload 0.82x
|
|
411
|
+
* tool schema 0.75x
|
|
412
|
+
* paths and flags 0.73x
|
|
413
|
+
* minified JSON 0.65x
|
|
414
|
+
* mixed CJK + code 0.65x
|
|
415
|
+
*
|
|
416
|
+
* The reason is BPE merges COMMON BYTE PAIRS. English prose is full of repeated whole words, so four
|
|
417
|
+
* characters per token holds. Code, JSON and CLI flags are punctuation-dense: `{"a":1,"b":2}` merges
|
|
418
|
+
* almost nothing, and a token covers about 2.6 characters. On a 300k context that 0.65x is a ~100k
|
|
419
|
+
* error — the exact direction and size of the gap being reported.
|
|
420
|
+
*
|
|
421
|
+
* Five classes, each with its measured rate:
|
|
422
|
+
*
|
|
423
|
+
* letters + spaces 4.2 the "4 chars/token" folklore, and the only place it holds
|
|
424
|
+
* digits 3.0 punctuation-free but rarely repeated
|
|
425
|
+
* punctuation + code 2.7 brackets, quotes, colons, operators — merge poorly
|
|
426
|
+
* whitespace (indent) 1.0 a run of spaces still costs a token per couple of columns
|
|
427
|
+
* CJK + other non-Latin 1.0 one token per character, occasionally two
|
|
428
|
+
*
|
|
429
|
+
* Counted in ONE pass, because this runs on every streamed chunk and every message body.
|
|
430
|
+
*/
|
|
431
|
+
function estimateBpe(s) {
|
|
432
|
+
if (!s) return 0;
|
|
433
|
+
let letters = 0; // a-z A-Z and the space/punctuation that surrounds prose
|
|
434
|
+
let digits = 0;
|
|
435
|
+
let punct = 0;
|
|
436
|
+
let space = 0;
|
|
437
|
+
let wide = 0;
|
|
438
|
+
|
|
439
|
+
for (const ch of s) {
|
|
440
|
+
const cp = ch.codePointAt(0);
|
|
441
|
+
if (cp > 127) { wide++; continue; }
|
|
442
|
+
if (cp === 32 || cp === 9) { space++; continue; }
|
|
443
|
+
if (cp >= 48 && cp <= 57) { digits++; continue; }
|
|
444
|
+
if ((cp >= 65 && cp <= 90) || (cp >= 97 && cp <= 122)) { letters++; continue; }
|
|
445
|
+
punct++;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Newlines land in `punct` (they are not 32 or 9), which is the right rate for them.
|
|
449
|
+
//
|
|
450
|
+
// The SPACE rate is the delicate one: a run of indentation costs about a token per two columns, but a
|
|
451
|
+
// single space between English words merges into the following word almost for free. Charging every
|
|
452
|
+
// space at the indentation rate made prose read 34% high — a worse error than the undercount it fixed,
|
|
453
|
+
// because prose is what the model WRITES back.
|
|
454
|
+
//
|
|
455
|
+
// So spaces are priced at a third of a token each: cheap enough for prose, and still enough that a
|
|
456
|
+
// deeply indented code block is not free.
|
|
457
|
+
return letters / 4.2 + digits / 3.0 + punct / 2.7 + space / 3.0 + wide;
|
|
458
|
+
}
|
|
382
459
|
export function estimateTokens(text) {
|
|
383
460
|
const s = String(text ?? '');
|
|
384
461
|
if (s.length <= TOK_CACHE_MAX_LEN) {
|
|
385
462
|
const hit = tokCache.get(s);
|
|
386
463
|
if (hit !== undefined) return hit;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
else nonAscii++;
|
|
391
|
-
}
|
|
392
|
-
const out = Math.ceil(ascii / 4) + nonAscii;
|
|
464
|
+
// Rounded UP from the same BPE-aware estimate the streaming counter uses. Both must agree: a body
|
|
465
|
+
// measured one way and a stream measured another is how a session ends up reporting two numbers.
|
|
466
|
+
const out = Math.ceil(estimateBpe(s));
|
|
393
467
|
if (tokCache.size >= TOK_CACHE_MAX) {
|
|
394
468
|
// Drop the oldest half in one go: cheaper than one-at-a-time and keeps the
|
|
395
469
|
// recently measured entries that a per-entry eviction would churn.
|
|
@@ -399,13 +473,9 @@ export function estimateTokens(text) {
|
|
|
399
473
|
tokCache.set(s, out);
|
|
400
474
|
return out;
|
|
401
475
|
}
|
|
402
|
-
// Long body:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (ch.codePointAt(0) <= 127) ascii++;
|
|
406
|
-
else nonAscii++;
|
|
407
|
-
}
|
|
408
|
-
return Math.ceil(ascii / 4) + nonAscii;
|
|
476
|
+
// Long body: measured directly, with the SAME estimator as the cached path. The two used to differ, so
|
|
477
|
+
// a long message and a short one were priced by different formulas.
|
|
478
|
+
return Math.ceil(estimateBpe(s));
|
|
409
479
|
}
|
|
410
480
|
|
|
411
481
|
// Estimate tokens for the MESSAGES only. The system prompt and tool definitions are
|
|
@@ -430,13 +500,52 @@ export function estimateMessageTokens(m, cfg) {
|
|
|
430
500
|
if (!m) return 0;
|
|
431
501
|
let total = estimateTokens(m.role || '');
|
|
432
502
|
const c = m.content;
|
|
433
|
-
|
|
503
|
+
if (typeof c === 'string') {
|
|
504
|
+
total += estimateTokens(c);
|
|
505
|
+
} else if (c && typeof c === 'object' && Array.isArray(c.media)) {
|
|
506
|
+
total += estimateMediaContentTokens(c);
|
|
507
|
+
} else {
|
|
508
|
+
total += estimateTokens(JSON.stringify(c || ''));
|
|
509
|
+
}
|
|
434
510
|
if (m.toolCalls) for (const tc of m.toolCalls) {
|
|
435
511
|
total += estimateTokens(tc.name || '') + estimateTokens(JSON.stringify(tc.args || ''));
|
|
436
512
|
}
|
|
437
513
|
return total;
|
|
438
514
|
}
|
|
439
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Tokens for a content block that carries media (an image read from disk, a screenshot).
|
|
518
|
+
*
|
|
519
|
+
* AN IMAGE IS NOT PRICED BY ITS BASE64 LENGTH. `JSON.stringify` on the same object counts every base64
|
|
520
|
+
* character as ordinary text — a 1 MB PNG is ~1.3M characters, which this estimator would price at
|
|
521
|
+
* roughly 400k tokens — while a provider charges by the image's PIXEL dimensions, usually one to two
|
|
522
|
+
* thousand. That is the direction that matters: the gauge would read hundreds of thousands too high, and
|
|
523
|
+
* auto-compaction would fire on a history that was never close to full.
|
|
524
|
+
*
|
|
525
|
+
* The text beside the image is priced normally; each image gets a flat estimate derived from the common
|
|
526
|
+
* tiling rule (a 512x512 tile ≈ 765 tokens, a 1568px max edge ≈ 1100 tokens). Exactness is impossible
|
|
527
|
+
* without decoding the image, and it does not matter here: images are rare, and being within a few
|
|
528
|
+
* hundred tokens beats being wrong by a hundred thousand.
|
|
529
|
+
*/
|
|
530
|
+
function estimateMediaContentTokens(c) {
|
|
531
|
+
let total = estimateTokens(String(c.text == null ? '' : c.text));
|
|
532
|
+
for (const m of c.media || []) {
|
|
533
|
+
if (!m || m.type !== 'image') continue;
|
|
534
|
+
// A thumbnail-sized image costs a tile; a full screenshot costs four. Read the dimensions from the
|
|
535
|
+
// metadata when the reader recorded them, and fall back to the large case otherwise — overestimating
|
|
536
|
+
// an image is far safer than under, because it makes compaction fire EARLY rather than late.
|
|
537
|
+
const w = Number(m.width) || 0;
|
|
538
|
+
const h = Number(m.height) || 0;
|
|
539
|
+
if (w > 0 && h > 0) {
|
|
540
|
+
const tiles = Math.max(1, Math.ceil(w / 512) * Math.ceil(h / 512));
|
|
541
|
+
total += Math.min(4, tiles) * 765;
|
|
542
|
+
} else {
|
|
543
|
+
total += 1100;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return total;
|
|
547
|
+
}
|
|
548
|
+
|
|
440
549
|
// ---- per-message token memo -----------------------------------------------
|
|
441
550
|
//
|
|
442
551
|
// Message objects -> { sig, tok }. WeakMap, so a message dropped from the history
|
|
@@ -474,7 +583,11 @@ function msgSignature(m) {
|
|
|
474
583
|
if (typeof c === 'string') s = 's' + c.length;
|
|
475
584
|
else if (c == null) s = 'n';
|
|
476
585
|
else if (Array.isArray(c)) s = 'a' + c.length;
|
|
477
|
-
else
|
|
586
|
+
else if (typeof c === 'object' && Array.isArray(c.media)) {
|
|
587
|
+
// A media block: its signature is the text plus one marker per image. The base64 payload itself is
|
|
588
|
+
// NOT hashed — it is the expensive part, and it never changes once the message exists.
|
|
589
|
+
s = 'm' + String(c.text == null ? '' : c.text).length + 'x' + c.media.length;
|
|
590
|
+
} else s = ''; // unknown shape: never trust a memo
|
|
478
591
|
if (m.toolCalls) s += 't' + m.toolCalls.length;
|
|
479
592
|
return s;
|
|
480
593
|
}
|
|
@@ -559,13 +672,43 @@ export class TokenLedger {
|
|
|
559
672
|
}
|
|
560
673
|
}
|
|
561
674
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
675
|
+
/**
|
|
676
|
+
* The overhead a request carries BEYOND its messages: the system prompt plus the tool definitions.
|
|
677
|
+
* Passed the assembled system text so the number matches what is actually sent.
|
|
678
|
+
*
|
|
679
|
+
* `cfg.toolDefTokens` is the REAL cost of the tool definitions, measured once by whoever built them.
|
|
680
|
+
* This used to charge a flat `n * 8` per tool, which is short by an order of magnitude: a tool's
|
|
681
|
+
* schema is its name, a paragraph of description and a nested parameters object, and measured here the
|
|
682
|
+
* 68 registered tools come to about 15 900 tokens — 29 times what 8 apiece reported.
|
|
683
|
+
*
|
|
684
|
+
* The discrepancy matters because this number is part of the context gauge. A gauge that omits ~15k of
|
|
685
|
+
* every request understates usage by that much per turn, and the user sees the provider's real figure
|
|
686
|
+
* disagree with the app's by a wide margin.
|
|
687
|
+
*
|
|
688
|
+
* The `n * 8` remains only as a FALLBACK for a caller that has not measured its definitions; it is
|
|
689
|
+
* better than nothing and is the old behaviour, exactly.
|
|
690
|
+
*/
|
|
565
691
|
export function estimateRequestOverhead(systemText, cfg) {
|
|
566
692
|
let total = estimateTokens(systemText || '');
|
|
693
|
+
const measured = cfg && Number.isFinite(cfg.toolDefTokens) ? cfg.toolDefTokens : null;
|
|
694
|
+
if (measured != null) return total + measured;
|
|
567
695
|
const n = (cfg && Array.isArray(cfg.toolFilter)) ? cfg.toolFilter.length : 0;
|
|
568
|
-
if (n) total += n * 8; // rough per-tool-definition overhead
|
|
696
|
+
if (n) total += n * 8; // rough per-tool-definition overhead, when nothing measured it
|
|
697
|
+
return total;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The token cost of a set of tool definitions, measured the way they are actually sent.
|
|
702
|
+
*
|
|
703
|
+
* Serialised as `{ name, description, parameters }` — the three fields the provider receives — and
|
|
704
|
+
* priced with the same estimator the gauge uses, so the number is comparable with everything else.
|
|
705
|
+
*/
|
|
706
|
+
export function measureToolDefTokens(tools) {
|
|
707
|
+
let total = 0;
|
|
708
|
+
for (const t of tools || []) {
|
|
709
|
+
if (!t || !t.name) continue;
|
|
710
|
+
total += estimateTokens(JSON.stringify({ name: t.name, description: t.description, parameters: t.parameters }));
|
|
711
|
+
}
|
|
569
712
|
return total;
|
|
570
713
|
}
|
|
571
714
|
|
package/src/tool-result.js
CHANGED
|
@@ -192,22 +192,37 @@ export function planTrim(messages, keepRatio, mustKeep) {
|
|
|
192
192
|
// A budget of 0 means "elide everything eligible"; a budget of 1 means "keep it
|
|
193
193
|
// all", in which case we still elide the already-marked ones so a repeated call
|
|
194
194
|
// is a no-op rather than a growing patch.
|
|
195
|
-
|
|
195
|
+
// The share of the ELIGIBLE text to keep, not of `totalBytes`. `totalBytes` counts the
|
|
196
|
+
// pointers left by earlier passes, so after a few passes it mostly describes text that can no
|
|
197
|
+
// longer be elided — and a budget drawn from it stops constraining the loop.
|
|
198
|
+
const eligibleBytes = candidates.reduce((n, i) => n + messages[i].content.length, 0);
|
|
199
|
+
const budget = eligibleBytes * ratio;
|
|
196
200
|
const keep = new Set();
|
|
197
201
|
let keepBytes = 0;
|
|
198
202
|
for (let k = candidates.length - 1; k >= 0; k--) {
|
|
199
203
|
const i = candidates[k];
|
|
200
204
|
if (i === mustKeep) { keep.add(i); keepBytes += messages[i].content.length; continue; }
|
|
201
205
|
const size = messages[i].content.length;
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
+
// Under budget: keep. Otherwise elide — except for the result the model is reading right
|
|
207
|
+
// now, which the `mustKeep` branch above has already kept.
|
|
208
|
+
//
|
|
209
|
+
// There used to be a `|| keep.size === 0` here, meaning "always keep at least the newest".
|
|
210
|
+
// `mustKeep` is what actually protects the live result, and the override made a single large
|
|
211
|
+
// result impossible to trim: it was the only candidate, so it was always "the newest", so
|
|
212
|
+
// the budget never applied and the trim elided nothing.
|
|
213
|
+
if (keepBytes + size <= budget) {
|
|
206
214
|
keep.add(i);
|
|
207
215
|
keepBytes += size;
|
|
208
216
|
}
|
|
209
217
|
}
|
|
210
218
|
|
|
219
|
+
// THE FLOOR: never elide everything.
|
|
220
|
+
//
|
|
221
|
+
// The budget can be smaller than every candidate — one 200k result against a 60k budget is the
|
|
222
|
+
// ordinary case, because a long turn leaves the history with only the newest result still
|
|
223
|
+
// un-elided. Without this the trim would replace that result with a pointer, which is worse
|
|
224
|
+
// than not trimming: the model loses the output it just asked for.
|
|
225
|
+
if (!keep.size && candidates.length) keep.add(candidates[candidates.length - 1]);
|
|
211
226
|
const elide = [];
|
|
212
227
|
let elideBytes = 0;
|
|
213
228
|
for (const i of candidates) {
|
|
@@ -259,6 +274,54 @@ export function applyTrim(messages, plan, sessionId) {
|
|
|
259
274
|
// so a caller can skip its UI event entirely.
|
|
260
275
|
export function trimToolResults(messages, opts = {}) {
|
|
261
276
|
const plan = planTrim(messages, opts.keepRatio, opts.mustKeep);
|
|
262
|
-
|
|
263
|
-
|
|
277
|
+
const mediaPlan = planMediaTrim(messages, opts.mediaKeep ?? 2);
|
|
278
|
+
if (!plan.elide.length && !mediaPlan.elide.length) return null;
|
|
279
|
+
const applied = plan.elide.length
|
|
280
|
+
? applyTrim(messages, plan, opts.sessionId)
|
|
281
|
+
: { elided: 0, elidedBytes: 0, kept: plan.keep.length, keptBytes: plan.keepBytes };
|
|
282
|
+
const mediaApplied = applyMediaTrim(messages, mediaPlan, opts.sessionId);
|
|
283
|
+
return {
|
|
284
|
+
...applied,
|
|
285
|
+
elided: applied.elided + mediaApplied.elided,
|
|
286
|
+
elidedBytes: applied.elidedBytes + mediaApplied.elidedBytes,
|
|
287
|
+
plan,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- media results ----------------------------------------------------------
|
|
292
|
+
// A media tool result keeps its base64 payload IN the history object — and unlike
|
|
293
|
+
// a string body, planTrim cannot elide it, so every screenshot was re-serialized
|
|
294
|
+
// into every request body, every session save and every cache-prefix walk until a
|
|
295
|
+
// full compaction dropped it. Keep only the newest `mediaKeep` media results;
|
|
296
|
+
// older ones spill their text (if any) and lose the bytes, same contract as the
|
|
297
|
+
// string path: saved to disk first, pointer left behind.
|
|
298
|
+
|
|
299
|
+
function planMediaTrim(messages, keep) {
|
|
300
|
+
const idx = [];
|
|
301
|
+
if (Array.isArray(messages)) {
|
|
302
|
+
for (let i = 0; i < messages.length; i++) {
|
|
303
|
+
const m = messages[i];
|
|
304
|
+
if (m && m.role === 'tool' && m.content && typeof m.content === 'object'
|
|
305
|
+
&& Array.isArray(m.content.media) && m.content.media.length) idx.push(i);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const elide = idx.slice(0, Math.max(0, idx.length - Math.max(0, keep)));
|
|
309
|
+
return { elide };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function applyMediaTrim(messages, mediaPlan, sessionId) {
|
|
313
|
+
const out = { elided: 0, elidedBytes: 0 };
|
|
314
|
+
for (const i of mediaPlan.elide) {
|
|
315
|
+
const m = messages[i];
|
|
316
|
+
let bytes = 0;
|
|
317
|
+
for (const part of m.content.media) bytes += (part && part.data ? part.data.length : 0);
|
|
318
|
+
const text = String(m.content.text || '');
|
|
319
|
+
const spilled = spillResult(sessionId, `trim-${m.toolCallId || i}`, text || '[media only]');
|
|
320
|
+
m.content = `[Media result elided to save context — ${fmtBytes(bytes)} of image data from ${m.name || 'tool'}`
|
|
321
|
+
+ (spilled ? `; the description was saved to: ${spilled.file}` : '; the description could not be saved')
|
|
322
|
+
+ '. Re-run the tool if you need the image again.]';
|
|
323
|
+
out.elided++;
|
|
324
|
+
out.elidedBytes += bytes;
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
264
327
|
}
|
package/src/tools/agent-swarm.js
CHANGED
|
@@ -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.
|
|
22
|
-
//
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
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}]
|
|
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
|
}
|