@aragon-agent/cli 0.6.21 → 0.6.23

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.
@@ -1,34 +1,59 @@
1
1
  /**
2
- * The dispatch supervisor (team-overseer).
2
+ * The dispatch supervisor (team-overseer, upgraded by subagent-overseer-v2).
3
3
  *
4
4
  * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope
5
5
  * (`glyphs.test.ts`), so no literal in this tree may hold a non-ASCII byte.
6
6
  *
7
7
  * A TIME CEILING ON A CHILD IS AN INSPECTION TRIGGER, NOT A DEATH SENTENCE.
8
- * When a child stalls (event silence) or simply runs long (the wall clock),
9
- * the supervisor looks at it through ONE bounded fast-tier call and decides:
8
+ * When a child stalls (event silence) or a cadence tick comes due, the
9
+ * supervisor looks at it through ONE bounded fast-tier call and decides:
10
10
  * keep waiting, steer a nudge in to help it recover, rebuild it with an
11
11
  * amended brief, or abandon it with a recorded reason. Only the supervisor
12
12
  * "decides"; the RUNTIME applies (see `runtime.ts`), because every child
13
13
  * lifecycle change must funnel through the worker loop that awaits it
14
14
  * (I-OV1).
15
15
  *
16
+ * THE SUPERVISOR IS A NO-LOOP, BOUNDED, REMEMBERED CALLEE - NOT AN AGENT
17
+ * (subagent-overseer-v2 D-1). The requirement says "a dedicated supervising
18
+ * subagent on the fast model"; it is satisfied by a per-dispatch, remembered,
19
+ * cadence-woken caller on the fast tier rather than a real `Agent.prompt()`
20
+ * loop, because a supervisor that can itself wedge needs a second supervisor.
21
+ *
22
+ * TWO INSPECTION SHAPES (D-3). Event silence triggers a SINGLE-child look
23
+ * (fast reaction to a suspected stall, unchanged from round 1); the cadence
24
+ * ladder triggers a BATCH look that carries every live child in one digest
25
+ * and returns one JSON array of per-child decisions. The batch call carries
26
+ * its own output budget (`overseerBatchOutputTokens`, R-P0-1): a full
27
+ * ten-child reply does not physically fit in the single-inspection 512
28
+ * tokens, and a truncated reply would parse to nothing - all wait, exactly
29
+ * when help is most needed.
30
+ *
16
31
  * THE TRANSPORT IS THE REVIEWER'S, DELIBERATELY. One `complete` call, no
17
- * tools, thinking off, temperature 0, output capped at
18
- * `FAST_LIMITS.reviewOutputTokens` and wall clock at
19
- * `FAST_LIMITS.reviewTimeoutMs` — a supervision call that could itself hang
20
- * would need a second supervisor, which is the regress this design refuses.
32
+ * tools, thinking off, temperature 0, output capped (512 single /
33
+ * 2048 batch) and wall clock at `FAST_LIMITS.reviewTimeoutMs` — a
34
+ * supervision call that could itself hang would need a second supervisor,
35
+ * which is the regress this design refuses.
21
36
  *
22
37
  * REPAIR, NEVER REJECT (the `normalizeSubagentSpecs` rule). An unparseable
23
38
  * or out-of-budget decision becomes `wait` with the default interval: the
24
39
  * failure mode of a supervisor must be "looked and let it run", never
25
- * "looked and killed it" (I-OV2).
40
+ * "looked and killed it" (I-OV2). In a batch, one bad ENTRY degrades only
41
+ * that entry; a wholly unparseable reply degrades the whole batch to wait
42
+ * with zero action side effects (R-P0-1 / AC-13).
26
43
  */
27
44
  import { resolveFastTier } from '../fast/resolve.js';
28
- import { assistantText } from '../fast/review-call.js';
45
+ import { accumulateUsage, assistantText, usageOf } from '../fast/review-call.js';
29
46
  import { FAST_LIMITS } from '../fast/limits.js';
30
47
  import { truncateBytes } from './report.js';
31
48
  import { TEAM_LIMITS } from './limits.js';
49
+ /**
50
+ * Bumped whenever either supervisor protocol prompt changes (D-9), so a
51
+ * behaviour report can be tied to a prompt revision with one grep.
52
+ * `TEAM_BLOCK_VERSION` covers only the LEAD's `<team>` block, not these.
53
+ *
54
+ * v3 adds the turn-cap trigger to the single-inspection prompt (F-8 A'2).
55
+ */
56
+ export const OVERSEER_PROMPT_VERSION = 'v3-2026-10';
32
57
  /** How many trailing history messages the digest carries. */
33
58
  const DIGEST_TAIL_MESSAGES = 8;
34
59
  /** Per-message text clamp inside the digest. */
@@ -55,6 +80,13 @@ function messageText(message) {
55
80
  function clampChars(text, max) {
56
81
  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 3))}...`;
57
82
  }
83
+ /** One line per trigger, as the digest names it (a union VALUE, never prose). */
84
+ const TRIGGER_LABELS = {
85
+ silence: 'event silence (suspected stall)',
86
+ clock: 'wall-clock check',
87
+ turncap: 'turn cap reached (inspection trigger, not a stop)',
88
+ postmortem: 'post-mortem (child already ended)',
89
+ };
58
90
  /**
59
91
  * Build the bounded text one inspection reads.
60
92
  *
@@ -68,7 +100,7 @@ export function buildChildDigest(input) {
68
100
  const { run, childAlive, trigger, messages, memory, elapsedSec } = input;
69
101
  const lines = [];
70
102
  lines.push(`child: ${run.label} "${run.description}"`);
71
- lines.push(`trigger: ${trigger === 'silence' ? 'event silence (suspected stall)' : trigger === 'clock' ? 'wall-clock check' : 'post-mortem (child already ended)'}`);
103
+ lines.push(`trigger: ${TRIGGER_LABELS[trigger]}`);
72
104
  lines.push(`elapsed: ${Math.round(elapsedSec)}s, alive: ${childAlive ? 'yes' : 'no'}`);
73
105
  lines.push(`phase: ${run.phase}, turns: ${run.turns}, toolCalls: ${run.toolCalls}` +
74
106
  (run.lastTool ? `, lastTool: ${run.lastTool}` : ''));
@@ -94,6 +126,51 @@ export function buildChildDigest(input) {
94
126
  }
95
127
  return truncateBytes(lines.join('\n'), TEAM_LIMITS.overseerDigestBytes);
96
128
  }
129
+ /**
130
+ * The per-child sections of one batch payload (D-3).
131
+ *
132
+ * SORTED BY LABEL so the same roster produces the byte-identical payload -
133
+ * the batch is the one place where ordering is observable to the model and
134
+ * to every test that asserts on it. Each child's digest is clamped to
135
+ * `overseerPerChildDigestBytes` (facts first, history tail last - R-P2-5).
136
+ * The whole-batch ceiling is applied by the joiner in the provider request.
137
+ */
138
+ export function buildBatchSections(children) {
139
+ return [...children]
140
+ .sort((a, b) => a.run.label.localeCompare(b.run.label, 'en'))
141
+ .map((child) => ({
142
+ label: child.run.label,
143
+ goal: child.run.description,
144
+ digest: truncateBytes(buildChildDigest(child), TEAM_LIMITS.overseerPerChildDigestBytes),
145
+ }));
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Cadence ladder (D-2) - pure math, so tests assert arithmetic, not timers
149
+ // ---------------------------------------------------------------------------
150
+ /**
151
+ * The FIRST cadence check, from the D-8 priority: `overseerIntervalMs` when
152
+ * the user named one; otherwise a positive `subagentTimeoutMs` (compat with
153
+ * users who already treated it as a trigger); otherwise the structural
154
+ * default. Read-only derivation - the user's stored config is never
155
+ * rewritten to "resolve" this.
156
+ */
157
+ export function resolveOverseerFirstCheckMs(team) {
158
+ if (team.overseerIntervalMs > 0)
159
+ return team.overseerIntervalMs;
160
+ if (team.subagentTimeoutMs > 0)
161
+ return team.subagentTimeoutMs;
162
+ return TEAM_LIMITS.overseerDefaultCheckMs;
163
+ }
164
+ /**
165
+ * The next ladder step after one that produced no action: grow by
166
+ * `overseerCadenceRatio`, clamped to the structural range. With the shipped
167
+ * 300 s base the checks land at 300 / 780 / 1548 / 2448 / ... seconds - a
168
+ * healthy slow child is looked at logarithmically in its run time (AC-11).
169
+ */
170
+ export function nextOverseerLadderStepMs(previousMs) {
171
+ const grown = Math.round(previousMs * TEAM_LIMITS.overseerCadenceRatio);
172
+ return Math.min(TEAM_LIMITS.overseerNextCheckMaxMs, Math.max(TEAM_LIMITS.overseerNextCheckMinMs, grown));
173
+ }
97
174
  // ---------------------------------------------------------------------------
98
175
  // Decision normalization
99
176
  // ---------------------------------------------------------------------------
@@ -119,8 +196,39 @@ function waitDecision(reason, nextCheckMs) {
119
196
  * replaced or abandoned, never waited on or nudged, so those normalize to
120
197
  * `abandon` rather than being rejected — a post-mortem answer the protocol
121
198
  * cannot honour must still produce a decision the runtime can apply.
199
+ *
200
+ * The single-inspection char limits (200/1200) are the defaults here; the
201
+ * batch path calls `normalizeWithLimits` directly with the tighter protocol
202
+ * limits (R-P0-1).
122
203
  */
123
204
  export function normalizeOverseerDecision(raw, childAlive) {
205
+ return normalizeWithLimits(raw, childAlive, {
206
+ reasonChars: TEAM_LIMITS.overseerReasonChars,
207
+ guidanceChars: TEAM_LIMITS.overseerGuidanceChars,
208
+ });
209
+ }
210
+ /**
211
+ * Parse the model's answer text as one JSON decision object.
212
+ *
213
+ * Forgiving on PURPOSE: models wrap JSON in prose and fences. The FIRST `{`
214
+ * to the LAST `}` is the only span tried, and anything that does not parse is
215
+ * `wait` — the caller never sees a throw from parsing.
216
+ */
217
+ export function parseOverseerDecisionText(text, childAlive) {
218
+ const first = text.indexOf('{');
219
+ const last = text.lastIndexOf('}');
220
+ if (first >= 0 && last > first) {
221
+ try {
222
+ return normalizeOverseerDecision(JSON.parse(text.slice(first, last + 1)), childAlive);
223
+ }
224
+ catch {
225
+ // Fall through to the safe default.
226
+ }
227
+ }
228
+ return waitDecision('decision unparseable; defaulting to wait');
229
+ }
230
+ /** Normalize with EXPLICIT limits so the two protocols stay independently tunable. */
231
+ function normalizeWithLimits(raw, childAlive, limits) {
124
232
  const obj = (raw && typeof raw === 'object' ? raw : {});
125
233
  const action = typeof obj.action === 'string' && VALID_ACTIONS.has(obj.action)
126
234
  ? obj.action
@@ -129,7 +237,7 @@ export function normalizeOverseerDecision(raw, childAlive) {
129
237
  ? obj.reason.trim()
130
238
  : 'no reason given';
131
239
  const guidance = typeof obj.guidance === 'string' && obj.guidance.trim().length > 0
132
- ? clampChars(obj.guidance.trim(), TEAM_LIMITS.overseerGuidanceChars)
240
+ ? clampChars(obj.guidance.trim(), limits.guidanceChars)
133
241
  : undefined;
134
242
  const nextCheckMs = clampNextCheckMs(obj.nextCheckMs);
135
243
  if (!childAlive && (action === 'wait' || action === 'nudge')) {
@@ -140,35 +248,41 @@ export function normalizeOverseerDecision(raw, childAlive) {
140
248
  }
141
249
  return {
142
250
  action,
143
- reason: clampChars(reason, TEAM_LIMITS.overseerReasonChars),
251
+ reason: clampChars(reason, limits.reasonChars),
144
252
  ...(guidance !== undefined && action === 'nudge' ? { guidance } : {}),
145
253
  ...(action === 'wait' && nextCheckMs !== undefined ? { nextCheckMs } : {}),
146
254
  };
147
255
  }
148
256
  /**
149
- * Parse the model's answer text as one JSON decision object.
257
+ * Parse the model's answer text as a JSON ARRAY of per-child decisions (D-3).
150
258
  *
151
- * Forgiving on PURPOSE: models wrap JSON in prose and fences. The FIRST `{`
152
- * to the LAST `}` is the only span tried, and anything that does not parse is
153
- * `wait` — the caller never sees a throw from parsing.
259
+ * Same forgiving span rule as the single path - first `[` to last `]` - and
260
+ * the same failure semantics: a reply that is not an array (prose, a single
261
+ * object, or TRUNCATED output with no closing `]`) is `[]`, which the caller
262
+ * turns into all-wait with zero action side effects (R-P0-1 / AC-13). Never
263
+ * throws.
154
264
  */
155
- export function parseOverseerDecisionText(text, childAlive) {
156
- const first = text.indexOf('{');
157
- const last = text.lastIndexOf('}');
265
+ export function parseOverseerBatchText(text) {
266
+ const first = text.indexOf('[');
267
+ const last = text.lastIndexOf(']');
158
268
  if (first >= 0 && last > first) {
159
269
  try {
160
- return normalizeOverseerDecision(JSON.parse(text.slice(first, last + 1)), childAlive);
270
+ const parsed = JSON.parse(text.slice(first, last + 1));
271
+ if (Array.isArray(parsed))
272
+ return parsed;
161
273
  }
162
274
  catch {
163
- // Fall through to the safe default.
275
+ // Fall through to the empty batch.
164
276
  }
165
277
  }
166
- return waitDecision('decision unparseable; defaulting to wait');
278
+ return [];
167
279
  }
168
280
  const OVERSEER_SYSTEM_PROMPT = [
169
281
  'You are the supervisor of a team of subagents. You are shown ONE subagent',
170
- 'that crossed a time trigger (event silence or a wall-clock check) and must',
171
- 'decide what to do with it. Answer with ONE JSON object and nothing else:',
282
+ 'that crossed a supervision trigger (event silence, a wall-clock check, or',
283
+ 'the configured turn cap) and must decide what to do with it. Crossing a',
284
+ 'ceiling does NOT stop the child - your decision does. Answer with ONE JSON',
285
+ 'object and nothing else:',
172
286
  '{"action": "wait" | "nudge" | "replace" | "abandon", "reason": "<= 200 chars",',
173
287
  ' "guidance": "<= 1200 chars, nudge only", "nextCheckMs": <ms, wait only>}.',
174
288
  'wait: the child looks healthy or is legitimately blocked (API backoff,',
@@ -183,6 +297,30 @@ const OVERSEER_SYSTEM_PROMPT = [
183
297
  'A nudge is only delivered after the child finishes its current tool call;',
184
298
  'a long-running tool is not a stall. Prefer the cheapest action that works.',
185
299
  ].join('\n');
300
+ const OVERSEER_BATCH_SYSTEM_PROMPT = [
301
+ 'You are the supervisor of a team of subagents. You are shown EVERY live',
302
+ 'subagent of one dispatch, separated by --- lines, on a periodic check.',
303
+ 'Decide ONCE PER SUBAGENT. Answer with ONE JSON array and nothing else -',
304
+ 'one object per subagent, using exactly the label shown:',
305
+ '[{"label": "<label>", "action": "wait" | "nudge" | "replace" | "abandon",',
306
+ ' "reason": "<= 80 chars", "guidance": "<= 400 chars, nudge only",',
307
+ ' "nextCheckMs": <ms, wait only>}, ...]',
308
+ 'Keep every reason and guidance SHORT: the whole array must fit a small',
309
+ 'output budget and a truncated array helps nobody.',
310
+ 'wait: the child looks healthy or is legitimately blocked (API backoff,',
311
+ ' waiting on a teammate or a human confirmation) - keep waiting, pick the',
312
+ ' next check delay.',
313
+ 'nudge: the child is stuck in a way a hint can fix - write the hint as',
314
+ ' guidance.',
315
+ 'replace: the child cannot recover - a fresh child will be built with your',
316
+ ' reason and guidance prepended to its brief.',
317
+ 'abandon: the task is not worth more attempts - the child is stopped and',
318
+ ' your reason is reported to the lead.',
319
+ 'You may use whole-team judgement: a child far behind healthy peers on the',
320
+ 'same brief is a different signal than one slow child alone.',
321
+ 'A nudge is only delivered after the child finishes its current tool call;',
322
+ 'a long-running tool is not a stall. Prefer the cheapest action that works.',
323
+ ].join('\n');
186
324
  function buildInspectionRequest(params) {
187
325
  return {
188
326
  model: params.modelId,
@@ -196,44 +334,94 @@ function buildInspectionRequest(params) {
196
334
  signal: params.signal,
197
335
  };
198
336
  }
337
+ function buildBatchRequest(params) {
338
+ return {
339
+ model: params.modelId,
340
+ ...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
341
+ apiKey: params.apiKey,
342
+ systemPrompt: OVERSEER_BATCH_SYSTEM_PROMPT,
343
+ messages: [{ role: 'user', content: params.digest, timestamp: Date.now() }],
344
+ // NOT FAST_LIMITS.reviewOutputTokens: a ten-child array does not fit 512
345
+ // tokens, and a truncated reply parses to all-wait (R-P0-1 / AC-13).
346
+ maxTokens: TEAM_LIMITS.overseerBatchOutputTokens,
347
+ thinkingLevel: 'off',
348
+ temperature: 0,
349
+ signal: params.signal,
350
+ };
351
+ }
352
+ const NO_USAGE = { inputTokens: 0, outputTokens: 0 };
199
353
  /**
200
354
  * The fast-tier-backed provider, mirroring `createChildReviewer`'s shape so
201
355
  * the controller wires it the same way: shared transport, live predicates,
202
356
  * fail-soft (any failure resolves to `wait` — I-OV2).
357
+ *
358
+ * USAGE IS ACCUMULATED HERE (D-7), off every `AssistantMessage` the shared
359
+ * transport returns, along the same `usageOf` / `accumulateUsage` path the
360
+ * fast reviewer already proved. A reply without a usage block contributes
361
+ * nothing rather than a fabricated zero-cost total.
203
362
  */
204
363
  export function createFastOverseerProvider(deps) {
205
364
  const tier = () => resolveFastTier(deps.getConfig(), deps.hasKey);
365
+ let totalUsage = { ...NO_USAGE };
366
+ const call = async (build) => {
367
+ const resolved = tier();
368
+ if (!deps.available() || !resolved.ok)
369
+ return null;
370
+ const apiKey = deps.getApiKey(resolved.ref.providerId, 'fast');
371
+ if (apiKey === undefined)
372
+ return null;
373
+ const controller = new AbortController();
374
+ const timer = setTimeout(() => controller.abort(), FAST_LIMITS.reviewTimeoutMs);
375
+ try {
376
+ const message = await deps.complete(resolved.ref.providerId, build(resolved.ref, apiKey, controller.signal));
377
+ totalUsage = accumulateUsage(totalUsage, usageOf(message).usage);
378
+ return assistantText(message);
379
+ }
380
+ finally {
381
+ clearTimeout(timer);
382
+ }
383
+ };
206
384
  return {
207
385
  active: () => deps.available() && tier().ok,
208
386
  async inspect(req) {
209
- const resolved = tier();
210
- if (!deps.available() || !resolved.ok) {
211
- return waitDecision('fast tier unavailable; defaulting to wait');
212
- }
213
- const apiKey = deps.getApiKey(resolved.ref.providerId, 'fast');
214
- if (apiKey === undefined) {
215
- return waitDecision('no fast-tier key; defaulting to wait');
216
- }
217
- const controller = new AbortController();
218
- const timer = setTimeout(() => controller.abort(), FAST_LIMITS.reviewTimeoutMs);
219
387
  try {
220
- const message = await deps.complete(resolved.ref.providerId, buildInspectionRequest({
388
+ const text = await call((ref, apiKey, signal) => buildInspectionRequest({
221
389
  digest: req.digest,
222
- modelId: resolved.ref.modelId,
223
- ...(resolved.ref.baseUrl ? { baseUrl: resolved.ref.baseUrl } : {}),
390
+ modelId: ref.modelId,
391
+ ...(ref.baseUrl ? { baseUrl: ref.baseUrl } : {}),
224
392
  apiKey,
225
- signal: controller.signal,
393
+ signal,
226
394
  }));
227
- return parseOverseerDecisionText(assistantText(message), true);
395
+ if (text === null)
396
+ return waitDecision('fast tier unavailable; defaulting to wait');
397
+ return parseOverseerDecisionText(text, true);
228
398
  }
229
399
  catch {
230
400
  // INCLUDING a timeout abort: supervision failing is wait, never kill.
231
401
  return waitDecision('inspection call failed; defaulting to wait');
232
402
  }
233
- finally {
234
- clearTimeout(timer);
403
+ },
404
+ async inspectBatch(req) {
405
+ try {
406
+ // The joiner owns the WHOLE-BATCH ceiling (D-3): sections arrive
407
+ // per-child-clamped and label-sorted; `hardMaxSubagents` means the
408
+ // clamp is a guard, not a squeeze.
409
+ const digest = truncateBytes(req.map((r) => `### ${r.label}\n${r.digest}`).join('\n---\n'), TEAM_LIMITS.overseerBatchDigestBytes);
410
+ const text = await call((ref, apiKey, signal) => buildBatchRequest({
411
+ digest,
412
+ modelId: ref.modelId,
413
+ ...(ref.baseUrl ? { baseUrl: ref.baseUrl } : {}),
414
+ apiKey,
415
+ signal,
416
+ }));
417
+ return text ?? '';
418
+ }
419
+ catch {
420
+ // INCLUDING a timeout abort: supervision failing is wait, never kill.
421
+ return '';
235
422
  }
236
423
  },
424
+ usage: () => ({ ...totalUsage }),
237
425
  };
238
426
  }
239
427
  export const OVERSEER_DEFAULT_NEXT_CHECK_MS = TEAM_LIMITS.overseerDefaultCheckMs;
@@ -243,14 +431,27 @@ export const OVERSEER_DEFAULT_NEXT_CHECK_MS = TEAM_LIMITS.overseerDefaultCheckMs
243
431
  * THE CLASS NEVER TOUCHES A CHILD (I-OV1). It looks and decides; `runtime.ts`
244
432
  * steers, flags, aborts and rebuilds, because only the worker loop can apply
245
433
  * a lifecycle change without orphaning the replacement.
434
+ *
435
+ * LOOKS AND ACTIONS ARE SEPARATE LEDGERS (D-5). A LOOK is cost: every
436
+ * inspection of a child spends one, including a call that failed softly -
437
+ * which is what closes G-6 (a flaky transport used to burn the budget the
438
+ * same as a decision, and the supervisor went quiet exactly when the team
439
+ * was hardest to see). An ACTION is a mutation (nudge / replace / abandon)
440
+ * and is counted only when the runtime APPLIES it, through the `note*`
441
+ * methods below. A label skipped because an inspection is already in flight
442
+ * spends NOTHING (R-P1-4).
246
443
  */
247
444
  export class TeamOverseer {
248
445
  provider;
249
446
  options;
250
447
  memory = new Map();
251
- inspections = new Map();
448
+ looks = new Map();
449
+ nudges = new Map();
252
450
  replacements = new Map();
451
+ abandons = new Map();
253
452
  inFlight = new Set();
453
+ looksTotal = 0;
454
+ callCount = 0;
254
455
  constructor(provider, options) {
255
456
  this.provider = provider;
256
457
  this.options = options;
@@ -264,24 +465,62 @@ export class TeamOverseer {
264
465
  return false;
265
466
  }
266
467
  }
267
- inspectionsExhausted(label) {
268
- return (this.inspections.get(label) ?? 0) >= TEAM_LIMITS.overseerMaxInspectionsPerChild;
468
+ /** Whether ANY inspection of `label` is in flight (single or batch). */
469
+ isInFlight(label) {
470
+ return this.inFlight.has(label);
471
+ }
472
+ /** Per-child look budget spent (D-5). */
473
+ looksExhausted(label) {
474
+ return ((this.looks.get(label) ?? 0) >= TEAM_LIMITS.overseerMaxLooksPerChild ||
475
+ this.dispatchLooksExhausted());
476
+ }
477
+ /** The fool-proof dispatch-wide total (R-P1-6). */
478
+ dispatchLooksExhausted() {
479
+ return this.looksTotal >= TEAM_LIMITS.overseerMaxLooksPerDispatch;
480
+ }
481
+ nudgesLeft(label) {
482
+ return (this.nudges.get(label) ?? 0) < TEAM_LIMITS.overseerMaxNudgesPerChild;
269
483
  }
270
484
  replacementsLeft(label) {
271
485
  return (this.replacements.get(label) ?? 0) < TEAM_LIMITS.overseerMaxReplacementsPerChild;
272
486
  }
487
+ abandonsLeft(label) {
488
+ return (this.abandons.get(label) ?? 0) < TEAM_LIMITS.overseerMaxAbandonsPerChild;
489
+ }
490
+ /** Called by the runtime when it APPLIES a nudge, not when it merely reads one. */
491
+ noteNudge(label) {
492
+ this.nudges.set(label, (this.nudges.get(label) ?? 0) + 1);
493
+ }
273
494
  /** Called by the runtime when it APPLIES a replace, not when it merely reads one. */
274
495
  noteReplacement(label) {
275
496
  this.replacements.set(label, (this.replacements.get(label) ?? 0) + 1);
276
497
  }
498
+ /** Called by the runtime when it APPLIES an abandon, not when it merely reads one. */
499
+ noteAbandon(label) {
500
+ this.abandons.set(label, (this.abandons.get(label) ?? 0) + 1);
501
+ }
502
+ /** The provider's own accumulated inspection spend (D-7), zero when unreadable. */
503
+ usage() {
504
+ try {
505
+ return this.provider.usage();
506
+ }
507
+ catch {
508
+ return { ...NO_USAGE };
509
+ }
510
+ }
511
+ /** Provider invocations, including ones that failed softly (D-7). */
512
+ calls() {
513
+ return this.callCount;
514
+ }
277
515
  /**
278
- * ONE inspection. `null` means QUIET — not active, no budget left, or one
279
- * already in flight for this child — and the caller then does nothing,
516
+ * ONE single-child inspection (the silence-triggered path, round-1
517
+ * behaviour preserved). `null` means QUIET — not active, no budget left, or
518
+ * one already in flight for this child — and the caller then does nothing,
280
519
  * which is exactly what quiet should do.
281
520
  */
282
521
  async inspect(req) {
283
522
  const label = req.run.label;
284
- if (!this.isActive() || this.inspectionsExhausted(label) || this.inFlight.has(label)) {
523
+ if (!this.isActive() || this.looksExhausted(label) || this.inFlight.has(label)) {
285
524
  return null;
286
525
  }
287
526
  this.inFlight.add(label);
@@ -295,6 +534,7 @@ export class TeamOverseer {
295
534
  elapsedSec: Math.max(0, (now - (req.run.startedAt ?? now)) / 1000),
296
535
  });
297
536
  let decision;
537
+ this.callCount += 1;
298
538
  try {
299
539
  decision = await this.provider.inspect({
300
540
  label,
@@ -308,7 +548,75 @@ export class TeamOverseer {
308
548
  finally {
309
549
  this.inFlight.delete(label);
310
550
  }
311
- this.inspections.set(label, (this.inspections.get(label) ?? 0) + 1);
551
+ this.spendLook(label);
552
+ this.remember(label, now, decision);
553
+ return decision;
554
+ }
555
+ /**
556
+ * ONE batch inspection over every requested child (the cadence path, D-3):
557
+ * a single fast-tier call, a JSON array back, one decision per child.
558
+ *
559
+ * SKIPS FIRST, CALLS SECOND (R-P1-4): a label whose single inspection is
560
+ * still in flight, or whose look budget is spent, simply gets NO entry -
561
+ * no decision, no look charged, ladder untouched by the caller. A label
562
+ * that IS carried but whose reply entry is missing or garbage gets `wait`
563
+ * (repair, never reject). A wholly unparseable or truncated reply is ALL
564
+ * wait with zero action side effects (R-P0-1 / AC-13).
565
+ */
566
+ async inspectBatch(reqs) {
567
+ const eligible = reqs.filter((req) => !this.inFlight.has(req.run.label) && !this.looksExhausted(req.run.label));
568
+ if (eligible.length === 0 || !this.isActive())
569
+ return new Map();
570
+ const now = this.options.now?.() ?? Date.now();
571
+ const inputs = eligible.map((req) => ({
572
+ run: req.run,
573
+ childAlive: req.childAlive,
574
+ trigger: 'clock',
575
+ messages: req.messages,
576
+ memory: this.memory.get(req.run.label) ?? [],
577
+ elapsedSec: Math.max(0, (now - (req.run.startedAt ?? now)) / 1000),
578
+ }));
579
+ const sections = buildBatchSections(inputs);
580
+ for (const req of eligible)
581
+ this.inFlight.add(req.run.label);
582
+ this.callCount += 1;
583
+ let raw;
584
+ try {
585
+ raw = await this.provider.inspectBatch(sections);
586
+ }
587
+ catch {
588
+ raw = [];
589
+ }
590
+ finally {
591
+ for (const req of eligible)
592
+ this.inFlight.delete(req.run.label);
593
+ }
594
+ const entries = typeof raw === 'string' ? parseOverseerBatchText(raw) : Array.isArray(raw) ? raw : [];
595
+ const byLabel = new Map();
596
+ for (const entry of entries) {
597
+ const label = entry?.label;
598
+ if (typeof label === 'string' && label.length > 0 && !byLabel.has(label)) {
599
+ byLabel.set(label, entry);
600
+ }
601
+ }
602
+ const decisions = new Map();
603
+ for (const input of inputs) {
604
+ const label = input.run.label;
605
+ const decision = normalizeWithLimits(byLabel.get(label), input.childAlive, {
606
+ reasonChars: TEAM_LIMITS.overseerBatchReasonChars,
607
+ guidanceChars: TEAM_LIMITS.overseerBatchGuidanceChars,
608
+ });
609
+ this.spendLook(label);
610
+ this.remember(label, now, decision);
611
+ decisions.set(label, decision);
612
+ }
613
+ return decisions;
614
+ }
615
+ spendLook(label) {
616
+ this.looks.set(label, (this.looks.get(label) ?? 0) + 1);
617
+ this.looksTotal += 1;
618
+ }
619
+ remember(label, now, decision) {
312
620
  const entries = this.memory.get(label) ?? [];
313
621
  entries.push({
314
622
  atSec: Math.max(0, Math.round((now - this.options.startedAt) / 1000)),
@@ -316,7 +624,6 @@ export class TeamOverseer {
316
624
  reason: decision.reason,
317
625
  });
318
626
  this.memory.set(label, entries);
319
- return decision;
320
627
  }
321
628
  }
322
629
  // ---------------------------------------------------------------------------
@@ -28,6 +28,15 @@ export interface ReportOptions {
28
28
  * THE ONE TABLE, because every child runs the lead's own model
29
29
  * (main-agent parity): one tier, one price, one honest total. */
30
30
  cost?: ModelCost;
31
+ /**
32
+ * The FAST tier's cost table, so the supervisor's own spend line can
33
+ * state real spend (subagent-overseer-v2 D-7 / R-P1-2). Supplied by
34
+ * `TaskToolDeps.fastModelCost()`; ABSENT when the fast model is not
35
+ * priced, in which case the spend line says `pricing unknown` rather
36
+ * than `$0.00` - unknown pricing is not zero pricing (the C-11 / RV-4
37
+ * rule the fast tier already follows everywhere else).
38
+ */
39
+ fastCost?: ModelCost;
31
40
  }
32
41
  /**
33
42
  * Cut `text` to at most `maxBytes` WITHOUT splitting a UTF-8 sequence.
@@ -21,7 +21,9 @@
21
21
  * because of one HTTP 429.
22
22
  */
23
23
  import { computeCost, formatCost, formatDuration, formatTokens } from '../agent/usage.js';
24
- import { TEAM_LIMITS } from './limits.js';
24
+ import { TEAM_AGGREGATE_LABEL, TEAM_LIMITS } from './limits.js';
25
+ /** The pseudo-label aggregate notices use; reserved at normalization. */
26
+ const SUPERVISOR_TEAM_LABEL = TEAM_AGGREGATE_LABEL;
25
27
  function byteLength(text) {
26
28
  return Buffer.byteLength(text, 'utf8');
27
29
  }
@@ -147,16 +149,42 @@ function buildSection(run) {
147
149
  * so the lead can see who was nudged or replaced, when, and why. Budgeted by
148
150
  * the same `reportMaxBytes` ladder as everything else - it renders as a block,
149
151
  * so trimming it means trimming the blocks, which the ladder already does.
152
+ *
153
+ * subagent-overseer-v2 appends three aggregates, each AT MOST ONCE (D-7 /
154
+ * D-10): the fast-tier spend line, a degraded-cadence note and a
155
+ * went-quiet note. Aggregates, never per-tick rows: a degraded dispatch
156
+ * ticks for hours and the report's job is the fact, not the log.
150
157
  */
151
- function buildOverseer(outcome) {
158
+ function buildOverseer(outcome, options) {
152
159
  const list = outcome.interventions ?? [];
153
- if (list.length === 0)
160
+ const calls = outcome.overseerCalls ?? 0;
161
+ const degraded = outcome.overseerDegraded === true;
162
+ // The pseudo-label aggregate (`team`) is a note, not a child's ledger row.
163
+ const quietChildren = list.filter((item) => item.trigger === 'quiet' && item.label !== SUPERVISOR_TEAM_LABEL).length;
164
+ if (list.length === 0 && calls === 0 && !degraded)
154
165
  return [];
155
166
  const lines = ['### Supervisor'];
156
167
  for (const item of list) {
157
168
  const at = Math.max(0, Math.round((item.at - outcome.startedAt) / 1000));
158
169
  lines.push(`[${item.trigger} ${at}s] ${item.label}: ${item.action} - ${item.reason}`);
159
170
  }
171
+ // Honest accounting (D-7): rendered only when calls were actually made,
172
+ // priced at the FAST table, `pricing unknown` when that table is absent.
173
+ if (calls > 0 && outcome.overseerUsage !== undefined) {
174
+ const price = options.fastCost
175
+ ? formatCost(computeCost(outcome.overseerUsage, options.fastCost))
176
+ : 'pricing unknown';
177
+ lines.push(`supervisor: ${calls} call${calls === 1 ? '' : 's'}, ${formatTokens(outcome.overseerUsage.inputTokens)} in / ${formatTokens(outcome.overseerUsage.outputTokens)} out tokens` +
178
+ ` (fast tier), ${price}.`);
179
+ }
180
+ if (degraded) {
181
+ lines.push(`note: the fast tier was unavailable for ${outcome.overseerDegradedTicks ?? 1}` +
182
+ ` cadence check(s); children waited unassisted and none were killed.`);
183
+ }
184
+ if (quietChildren > 0) {
185
+ lines.push(`note: supervision went quiet on ${quietChildren}` +
186
+ ` child${quietChildren === 1 ? '' : 'ren'} after its look budget.`);
187
+ }
160
188
  return lines;
161
189
  }
162
190
  function buildLeadMail(outcome) {
@@ -184,7 +212,7 @@ export function buildDispatchReport(outcome, options = {}) {
184
212
  const header = buildHeader(outcome, options);
185
213
  const sections = outcome.runs.map(buildSection);
186
214
  const mail = buildLeadMail(outcome);
187
- const overseer = buildOverseer(outcome);
215
+ const overseer = buildOverseer(outcome, options);
188
216
  const summaries = sections.map((s) => truncateBytes(s.summary, TEAM_LIMITS.summaryMaxBytes));
189
217
  const trimmed = summaries.map((s, i) => s.length < sections[i].summary.length);
190
218
  const render = (bodies) => {