@aragon-agent/cli 0.5.8 → 0.5.9

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/README.md +1429 -1163
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +40 -0
  9. package/dist/agent/reducer.d.ts +245 -3
  10. package/dist/agent/reducer.js +490 -17
  11. package/dist/agent/retry-view.d.ts +68 -0
  12. package/dist/agent/retry-view.js +95 -0
  13. package/dist/agent/system-prompt.d.ts +11 -0
  14. package/dist/agent/system-prompt.js +2 -0
  15. package/dist/cli.js +86 -2
  16. package/dist/commands/builtins.d.ts +1 -1
  17. package/dist/commands/builtins.js +308 -2
  18. package/dist/config/cli-commands.d.ts +53 -2
  19. package/dist/config/cli-commands.js +136 -3
  20. package/dist/config/env.d.ts +6 -3
  21. package/dist/config/env.js +103 -3
  22. package/dist/config/load.d.ts +49 -0
  23. package/dist/config/load.js +83 -1
  24. package/dist/config/schema.d.ts +212 -2
  25. package/dist/config/schema.js +207 -1
  26. package/dist/config/store.js +20 -1
  27. package/dist/fast/critique.d.ts +43 -0
  28. package/dist/fast/critique.js +62 -0
  29. package/dist/fast/digest.d.ts +42 -0
  30. package/dist/fast/digest.js +85 -0
  31. package/dist/fast/frames.d.ts +99 -0
  32. package/dist/fast/frames.js +180 -0
  33. package/dist/fast/limits.d.ts +98 -0
  34. package/dist/fast/limits.js +98 -0
  35. package/dist/fast/prompt.d.ts +72 -0
  36. package/dist/fast/prompt.js +94 -0
  37. package/dist/fast/resolve.d.ts +43 -0
  38. package/dist/fast/resolve.js +105 -0
  39. package/dist/fast/review-call.d.ts +99 -0
  40. package/dist/fast/review-call.js +130 -0
  41. package/dist/fast/reviewer.d.ts +248 -0
  42. package/dist/fast/reviewer.js +664 -0
  43. package/dist/fast/types.d.ts +128 -0
  44. package/dist/fast/types.js +14 -0
  45. package/dist/fast/wiring.d.ts +170 -0
  46. package/dist/fast/wiring.js +316 -0
  47. package/dist/logging/install.d.ts +15 -1
  48. package/dist/logging/install.js +37 -4
  49. package/dist/logging/logger.d.ts +1 -1
  50. package/dist/session/persist.js +57 -1
  51. package/dist/team/normalize.d.ts +26 -1
  52. package/dist/team/normalize.js +23 -3
  53. package/dist/team/prompt.d.ts +11 -0
  54. package/dist/team/prompt.js +10 -0
  55. package/dist/team/report.d.ts +18 -0
  56. package/dist/team/report.js +27 -1
  57. package/dist/team/runtime.d.ts +13 -2
  58. package/dist/team/runtime.js +33 -6
  59. package/dist/team/subagent.d.ts +19 -1
  60. package/dist/team/subagent.js +72 -10
  61. package/dist/team/task-tool.d.ts +26 -0
  62. package/dist/team/task-tool.js +48 -4
  63. package/dist/team/types.d.ts +80 -1
  64. package/dist/tools/bash-tool.js +19 -3
  65. package/dist/tools/diff.d.ts +25 -5
  66. package/dist/tools/diff.js +72 -41
  67. package/dist/tools/file-change-store.d.ts +34 -0
  68. package/dist/tools/file-change-store.js +57 -0
  69. package/dist/tools/fs-tools.d.ts +26 -0
  70. package/dist/tools/fs-tools.js +62 -5
  71. package/dist/tools/index.d.ts +31 -0
  72. package/dist/tools/index.js +7 -1
  73. package/dist/tools/patch.d.ts +115 -0
  74. package/dist/tools/patch.js +453 -0
  75. package/dist/tools/terminal-output.d.ts +107 -0
  76. package/dist/tools/terminal-output.js +198 -0
  77. package/dist/tools/tool-output-store.d.ts +68 -0
  78. package/dist/tools/tool-output-store.js +102 -0
  79. package/dist/ui/ActivityLine.d.ts +48 -0
  80. package/dist/ui/ActivityLine.js +22 -0
  81. package/dist/ui/App.d.ts +1 -4
  82. package/dist/ui/App.js +273 -37
  83. package/dist/ui/BottomStatusRow.d.ts +43 -0
  84. package/dist/ui/BottomStatusRow.js +14 -0
  85. package/dist/ui/Composer.d.ts +1 -6
  86. package/dist/ui/Composer.js +2 -2
  87. package/dist/ui/PromptInput.d.ts +1 -15
  88. package/dist/ui/PromptInput.js +1 -38
  89. package/dist/ui/StatusBar.d.ts +37 -0
  90. package/dist/ui/StatusBar.js +5 -3
  91. package/dist/ui/TeamPanel.d.ts +5 -4
  92. package/dist/ui/TeamPanel.js +1 -1
  93. package/dist/ui/TodoPanel.d.ts +5 -4
  94. package/dist/ui/Transcript.d.ts +26 -3
  95. package/dist/ui/Transcript.js +76 -10
  96. package/dist/ui/activity-phrases.d.ts +33 -0
  97. package/dist/ui/activity-phrases.js +50 -0
  98. package/dist/ui/entries/AssistantEntry.d.ts +20 -0
  99. package/dist/ui/entries/AssistantEntry.js +3 -2
  100. package/dist/ui/entries/DiffView.d.ts +67 -0
  101. package/dist/ui/entries/DiffView.js +103 -0
  102. package/dist/ui/entries/FastCard.d.ts +53 -0
  103. package/dist/ui/entries/FastCard.js +87 -0
  104. package/dist/ui/entries/RetryCard.d.ts +56 -0
  105. package/dist/ui/entries/RetryCard.js +60 -0
  106. package/dist/ui/entries/TeamCard.js +6 -1
  107. package/dist/ui/entries/ToolCard.d.ts +38 -1
  108. package/dist/ui/entries/ToolCard.js +68 -5
  109. package/dist/ui/glyphs.d.ts +9 -0
  110. package/dist/ui/glyphs.js +2 -0
  111. package/dist/ui/layout/AppShell.d.ts +13 -22
  112. package/dist/ui/layout/AppShell.js +3 -38
  113. package/dist/ui/layout/virtual-window.d.ts +1 -1
  114. package/dist/ui/layout/virtual-window.js +109 -5
  115. package/dist/ui/overlays/HelpOverlay.js +16 -2
  116. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  117. package/dist/ui/overlays/SettingsScreen.js +147 -1
  118. package/dist/ui/transcript-text.js +27 -0
  119. package/dist/ui/use-wheel-routing.d.ts +16 -32
  120. package/dist/ui/use-wheel-routing.js +21 -38
  121. package/package.json +2 -2
  122. package/dist/ui/layout/regions.d.ts +0 -55
  123. package/dist/ui/layout/regions.js +0 -52
@@ -251,17 +251,50 @@ export function attachAgentEvents(logger, controller) {
251
251
  ms: turnStartedAt > 0 ? Date.now() - turnStartedAt : 0,
252
252
  });
253
253
  break;
254
- case 'message_update':
254
+ case 'message_update': {
255
+ // API retry (llm-api-retry-backoff §6.8), ABOVE the `trace` line so a
256
+ // three-minute pause is in the log at the DEFAULT level. Redaction is
257
+ // unaffected: the payload carries no user content, and `message` is already
258
+ // the truncated body `classifyHttpError` produces.
259
+ const se = event.streamEvent;
260
+ if (se?.type === 'retry_scheduled') {
261
+ const cfg = controller.getConfig();
262
+ logger.warn('agent', 'llm_retry_scheduled', {
263
+ attempt: se.attempt,
264
+ maxRetries: se.maxRetries,
265
+ delayMs: se.delayMs,
266
+ errorType: se.errorType,
267
+ retryAfterMs: se.retryAfterMs,
268
+ providerId: cfg.provider,
269
+ modelId: cfg.model,
270
+ });
271
+ break;
272
+ }
273
+ if (se?.type === 'retry_attempt') {
274
+ logger.debug('agent', 'llm_retry_attempt', {
275
+ attempt: se.attempt,
276
+ maxRetries: se.maxRetries,
277
+ });
278
+ break;
279
+ }
280
+ if (se?.type === 'stream_restart') {
281
+ logger.warn('agent', 'llm_stream_restart', {
282
+ attempt: se.attempt,
283
+ discarded: se.discardedToolCallIds?.length ?? 0,
284
+ });
285
+ break;
286
+ }
255
287
  logger.trace('agent', 'stream', {
256
- type: event.streamEvent?.type ?? 'unknown',
288
+ type: se?.type ?? 'unknown',
257
289
  // The SIZE of the increment, never the increment. It is what makes a
258
290
  // trace log usable for "where did the time go" without turning it
259
291
  // into a second copy of the conversation.
260
- delta: typeof event.streamEvent?.delta === 'string'
261
- ? event.streamEvent.delta.length
292
+ delta: typeof se?.delta === 'string'
293
+ ? se.delta.length
262
294
  : undefined,
263
295
  });
264
296
  break;
297
+ }
265
298
  case 'tool_execution_start':
266
299
  logger.debug('tool', 'tool_start', {
267
300
  toolCallId: event.toolCallId,
@@ -23,7 +23,7 @@
23
23
  import type { LogConfig } from '../config/schema.js';
24
24
  import { type LogLevelName } from './levels.js';
25
25
  /** Closed set, not free text: a typo'd scope is a record nobody can filter on. */
26
- export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'history';
26
+ export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history';
27
27
  export interface LogRecord {
28
28
  ts: string;
29
29
  lv: LogLevelName;
@@ -28,6 +28,37 @@ function defaultStamp() {
28
28
  // Filesystem-safe timestamp without punctuation that breaks on Windows.
29
29
  return new Date().toISOString().replace(/[:.]/g, '-');
30
30
  }
31
+ /**
32
+ * Drop the live tool tail on the way to disk (agent-activity-presentation-live
33
+ * D-24 / AC-34).
34
+ *
35
+ * THIS IS THE SERIALIZER, AND IT IS THE ONLY ONE (P1-2). `saveSession` writes
36
+ * `entries` VERBATIM -- the module doc below says so in those words -- and
37
+ * `agent/reducer.ts` contains no serializer at all, so a `/save` issued DURING a
38
+ * run would capture `live` / `liveSeq` / `lastOutputAt` however carefully the
39
+ * reducer were written.
40
+ *
41
+ * Three reasons to drop it, in order of weight: a resumed session's tail
42
+ * describes a process that no longer exists; the settled `preview` supersedes it
43
+ * entirely, so it is strictly redundant; and it is the only mutable-while-live
44
+ * field a tool entry has, so persisting it would force a `normalizeLoadedEntries`
45
+ * clause -- the class of clause round 1's D-9 exists for.
46
+ *
47
+ * STRIPPING RATHER THAN TRUSTING ABSENCE IS WHAT MAKES DECLINING THAT CLAUSE
48
+ * HONEST. A tool entry saved mid-run keeps `status: 'running'` and
49
+ * `normalizeLoadedEntries` has no clause for tool entries (it never has -- the
50
+ * four it carries are all about other kinds). With the tail stripped, `showLive`
51
+ * is false and the resumed card is byte-identical to what today's build draws
52
+ * for the same file: one `running` row. With the tail present it would be a
53
+ * multi-row card describing a process that died with the last session, counting
54
+ * seconds against a `lastOutputAt` from yesterday.
55
+ */
56
+ function stripLiveToolOutput(entries) {
57
+ return entries.map((e) => e.kind === 'tool' &&
58
+ (e.live !== undefined || e.liveSeq !== undefined || e.lastOutputAt !== undefined)
59
+ ? { ...e, live: undefined, liveSeq: undefined, lastOutputAt: undefined }
60
+ : e);
61
+ }
31
62
  /**
32
63
  * `data` GAINS `todos`, AND WIDENING `SavedSession` ALONE IS NOT ENOUGH (P0-2):
33
64
  * the writer below builds its payload from THIS parameter, not from the type, so
@@ -43,7 +74,7 @@ export function saveSession(filePath, data) {
43
74
  savedAt: Date.now(),
44
75
  model: data.model,
45
76
  messages: data.messages,
46
- entries: data.entries,
77
+ entries: stripLiveToolOutput(data.entries),
47
78
  todos: data.todos,
48
79
  };
49
80
  writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
@@ -75,6 +106,31 @@ export function normalizeLoadedEntries(entries) {
75
106
  if (entry.kind === 'todo' && entry.live) {
76
107
  return { ...entry, live: false, interrupted: true };
77
108
  }
109
+ // THE THIRD CLAUSE (llm-api-retry-backoff §6.4 / R-10 / AC-26). A card saved
110
+ // mid-wait would resume claiming to be counting down to an instant in the past,
111
+ // and — worse — it would never settle, so `Transcript`'s MONOTONIC boundary
112
+ // would never advance past it and every frame for the rest of the session
113
+ // would re-render the whole tail. `resumeAt` is dropped for the same reason
114
+ // the phase changes: there is no attempt coming.
115
+ if (entry.kind === 'retry' && (entry.phase === 'waiting' || entry.phase === 'retrying')) {
116
+ return { ...entry, phase: 'interrupted', resumeAt: undefined };
117
+ }
118
+ // THE FOURTH CLAUSE (fast-model-tier §5.4 / C-5 / AC-28). A review card
119
+ // saved while its call was open resumes claiming to be running while nothing
120
+ // is, and — the part that actually costs — it never settles, so
121
+ // `Transcript`'s MONOTONIC boundary never advances past it and every frame
122
+ // for the rest of the session re-renders the whole tail.
123
+ //
124
+ // `dropped` rather than `failed`: the review did not fail, it never got an
125
+ // answer, and its critique died with the process.
126
+ if (entry.kind === 'fast' && entry.live) {
127
+ return {
128
+ ...entry,
129
+ live: false,
130
+ status: 'dropped',
131
+ detail: 'interrupted (session resumed)',
132
+ };
133
+ }
78
134
  return entry;
79
135
  });
80
136
  }
@@ -23,6 +23,26 @@ export interface NormalizedSpecs {
23
23
  specs: SubagentSpec[];
24
24
  /** How many entries the model asked for, before dropping and capping. */
25
25
  requested: number;
26
+ /**
27
+ * Specs that asked for `model:"fast"` and were downgraded to `main`
28
+ * (fast-model-tier §3.4 / R-6).
29
+ *
30
+ * ADDITIVE: `NormalizedSpecs` GAINS a field, it does not change one, so every
31
+ * existing caller and test compiles unchanged.
32
+ */
33
+ downgraded: number;
34
+ }
35
+ /** Options for the fast tier. Optional for the reason `normalizeSubagentSpecs`
36
+ * documents below (RV-14). */
37
+ export interface NormalizeOptions {
38
+ /**
39
+ * §3.3's LIVE predicate, evaluated at DISPATCH time (RV-3).
40
+ *
41
+ * `fast.delegate: false` and "the tier stopped resolving twenty minutes ago"
42
+ * are the same observable to the model — a downgrade, counted and reported —
43
+ * which is the honest reading of both.
44
+ */
45
+ fastAvailable?: boolean;
26
46
  }
27
47
  /**
28
48
  * Repair and bound a raw `subagents` array.
@@ -35,5 +55,10 @@ export interface NormalizedSpecs {
35
55
  * @param max The resolved `team.maxSubagents`; further clamped to
36
56
  * `TEAM_LIMITS.hardMaxSubagents` here so a hand-edited config file
37
57
  * cannot raise the requirement's ceiling (R-g).
58
+ * @param opts Fast-tier availability. OPTIONAL, DEFAULTING TO `false` (RV-14):
59
+ * this function and `NormalizedSpecs` are both exported and both
60
+ * have existing callers and tests, so a required third parameter
61
+ * would be a breaking edit that buys nothing — and an omitted one
62
+ * now means exactly what a pre-feature build meant.
38
63
  */
39
- export declare function normalizeSubagentSpecs(raw: unknown, max: number): NormalizedSpecs;
64
+ export declare function normalizeSubagentSpecs(raw: unknown, max: number, opts?: NormalizeOptions): NormalizedSpecs;
@@ -76,13 +76,21 @@ function dedupeLabel(label, taken) {
76
76
  * @param max The resolved `team.maxSubagents`; further clamped to
77
77
  * `TEAM_LIMITS.hardMaxSubagents` here so a hand-edited config file
78
78
  * cannot raise the requirement's ceiling (R-g).
79
+ * @param opts Fast-tier availability. OPTIONAL, DEFAULTING TO `false` (RV-14):
80
+ * this function and `NormalizedSpecs` are both exported and both
81
+ * have existing callers and tests, so a required third parameter
82
+ * would be a breaking edit that buys nothing — and an omitted one
83
+ * now means exactly what a pre-feature build meant.
79
84
  */
80
- export function normalizeSubagentSpecs(raw, max) {
85
+ export function normalizeSubagentSpecs(raw, max, opts = {}) {
81
86
  if (!Array.isArray(raw))
82
- return { specs: [], requested: 0 };
87
+ return { specs: [], requested: 0, downgraded: 0 };
83
88
  const requested = raw.length;
89
+ const fastAvailable = opts.fastAvailable === true;
84
90
  const kept = [];
85
91
  const taken = new Set();
92
+ /** Parallel to `kept`: did entry `i` ask for the fast tier? */
93
+ const wantedFast = [];
86
94
  for (let i = 0; i < raw.length; i += 1) {
87
95
  const item = raw[i];
88
96
  if (!item || typeof item !== 'object')
@@ -94,13 +102,25 @@ export function normalizeSubagentSpecs(raw, max) {
94
102
  const prompt = clamp(asString(src.prompt), TEAM_LIMITS.promptChars);
95
103
  if (prompt.length === 0)
96
104
  continue;
105
+ // REPAIR, NEVER REJECT — this file's own contract. `'fast'` without
106
+ // availability becomes `'main'` AND IS COUNTED; anything else (a typo, a
107
+ // model id, `undefined`) is simply `'main'` and is NOT counted, because the
108
+ // model did not ask for the fast tier and has nothing to be told about.
109
+ const wantsFast = src.model === 'fast';
110
+ wantedFast.push(wantsFast);
97
111
  kept.push({
98
112
  label: dedupeLabel(slugLabel(src.label, kept.length), taken),
99
113
  description,
100
114
  prompt,
101
115
  readOnly: src.readOnly === true,
116
+ tier: wantsFast && fastAvailable ? 'fast' : 'main',
102
117
  });
103
118
  }
104
119
  const ceiling = Math.max(1, Math.min(Math.floor(max) || 1, TEAM_LIMITS.hardMaxSubagents));
105
- return { specs: kept.slice(0, ceiling), requested };
120
+ const specs = kept.slice(0, ceiling);
121
+ // COUNTED OVER THE SURVIVORS, not over everything the model wrote: a spec the
122
+ // cap dropped never ran at all, so reporting it as "ran on the main model"
123
+ // would be a second, different lie in a line that exists to stop the first.
124
+ const downgraded = specs.filter((spec, i) => spec.tier === 'main' && wantedFast[i] === true).length;
125
+ return { specs, requested, downgraded };
106
126
  }
@@ -18,6 +18,17 @@ export { TEAM_BLOCK_VERSION };
18
18
  export interface TeamBlockParams {
19
19
  maxSubagents: number;
20
20
  maxConcurrent: number;
21
+ /**
22
+ * Whether `model:"fast"` is honoured on `task` right now (fast-model-tier
23
+ * §3.8).
24
+ *
25
+ * ONE SENTENCE, AND ONLY WHEN IT IS TRUE. `<fast_tier>` carries the full
26
+ * guidance; this is the cross-reference that stops a model reading
27
+ * `<team_mode>` in isolation and concluding every child costs the same. With
28
+ * it absent the block is BYTE-IDENTICAL to the pre-feature output, which is
29
+ * what `--no-fast` and every existing snapshot depend on.
30
+ */
31
+ fastDelegation?: boolean;
21
32
  }
22
33
  /**
23
34
  * The lead's `<team_mode>` block. Under 1200 characters on purpose: it is paid
@@ -47,6 +47,16 @@ export function buildTeamBlock(params) {
47
47
  'report back. Give each one a disjoint set of files to write. Subagents cannot',
48
48
  'dispatch further subagents and cannot ask the user anything.',
49
49
  '',
50
+ // THE TRAILING BLANK IS INSIDE THE CONDITIONAL, not outside it. An
51
+ // unconditional `''` here would add a line to EVERY team-enabled prompt and
52
+ // silently break the byte-identity `--no-fast` depends on (I-2).
53
+ ...(params.fastDelegation
54
+ ? [
55
+ 'A subagent doing mechanical, high-volume work can run on the cheaper fast',
56
+ 'model - see <fast_tier>.',
57
+ '',
58
+ ]
59
+ : []),
50
60
  'You get one combined report when they all finish. Read it before deciding what',
51
61
  'to do next, and tell the user what the team found in your own words.',
52
62
  '</team_mode>',
@@ -25,6 +25,24 @@ import type { DispatchOutcome, SubagentRun } from './types.js';
25
25
  export interface ReportOptions {
26
26
  /** The lead's model cost table, so the header can state real spend (R-5). */
27
27
  cost?: ModelCost;
28
+ /**
29
+ * The FAST model's cost table (fast-model-tier §3.4 / R-7).
30
+ *
31
+ * A SECOND TABLE, not a second number derived from the first. Summing
32
+ * fast-tier tokens at the lead's price over-reports a Haiku child under a
33
+ * Sonnet lead by roughly an order of magnitude, and the whole justification
34
+ * for delegating to a cheaper model is the figure on this line.
35
+ */
36
+ fastCost?: ModelCost;
37
+ /**
38
+ * The static price table has never heard of the fast model (C-11 / RV-4).
39
+ *
40
+ * UNKNOWN PRICING IS NOT ZERO PRICING. `buildRuntimeModel` hands back
41
+ * `cost: { input: 0, output: 0 }` for an unrecognised id, and the fast tier is
42
+ * precisely where an unrecognised id is LIKELY — so rendering it as `$0.00`
43
+ * would make the feature look free while it is spending money.
44
+ */
45
+ fastPricingUnknown?: boolean;
28
46
  }
29
47
  /**
30
48
  * Cut `text` to at most `maxBytes` WITHOUT splitting a UTF-8 sequence.
@@ -106,6 +106,28 @@ function buildHeader(outcome, options) {
106
106
  `(${counts}) in ${formatDuration(durationMs)}.`);
107
107
  lines.push(`Tokens: in ${formatTokens(outcome.usage.inputTokens)}, out ` +
108
108
  `${formatTokens(outcome.usage.outputTokens)}. Cost: ${formatCost(computeCost(outcome.usage, options.cost))}.`);
109
+ // A SECOND LINE, NOT A COMBINED TOTAL (fast-model-tier §3.4). Two tiers with
110
+ // two price tables have two answers, and folding them into one figure is the
111
+ // misattribution R-7 is about. Present only when a fast child actually ran, so
112
+ // an ordinary dispatch's header is byte-identical.
113
+ const fastUsage = outcome.fastUsage;
114
+ if (fastUsage) {
115
+ // `unknown` rather than a currency amount when the model has no price table
116
+ // (RV-4): a lower bound rendered as a total is the same class of lie as
117
+ // pricing a Haiku child at Sonnet rates.
118
+ const fastCostLabel = options.fastPricingUnknown
119
+ ? 'unknown (no price table)'
120
+ : formatCost(computeCost(fastUsage, options.fastCost));
121
+ lines.push(`Fast tier: in ${formatTokens(fastUsage.inputTokens)}, out ` +
122
+ `${formatTokens(fastUsage.outputTokens)}. Cost: ${fastCostLabel}.`);
123
+ }
124
+ // THE LINE THAT KEEPS A DOWNGRADE FROM BEING SILENT (R-6). Without it the
125
+ // model asked for a cheap child, got an expensive one, and has no way to learn
126
+ // that its cost model is wrong.
127
+ if (outcome.downgraded && outcome.downgraded > 0) {
128
+ const n = outcome.downgraded;
129
+ lines.push(`${n} ${n === 1 ? 'subagent' : 'subagents'} ran on the main model (fast tier off).`);
130
+ }
109
131
  for (const conflict of findFileConflicts(outcome.runs)) {
110
132
  const who = conflict.labels.map((l) => `"${l}"`).join(' and ');
111
133
  lines.push(`WARNING: agents ${who} both wrote ${conflict.path} - review before trusting either.`);
@@ -117,8 +139,12 @@ function buildSection(run) {
117
139
  // the only place the lead learns that a child's first attempt died before it
118
140
  // did anything (F-4).
119
141
  const retried = run.retries ? `, retried ${run.retries}x` : '';
142
+ // `[fast]` after the LABEL, on the head line, which is never trimmed. Six
143
+ // bytes, and it is the only place the lead learns which of its children were
144
+ // cheap — the fact the whole second cost line is about.
145
+ const tier = run.tier === 'fast' ? ' [fast]' : '';
120
146
  const head = [
121
- `### ${run.label} "${run.description}" ${statusBadge(run)} ${elapsed(run)}, ` +
147
+ `### ${run.label}${tier} "${run.description}" ${statusBadge(run)} ${elapsed(run)}, ` +
122
148
  `${run.turns} turns, ${run.toolCalls} tools${retried}`,
123
149
  ];
124
150
  if (run.filesTouched.length > 0)
@@ -21,7 +21,7 @@ import type { ProviderRegistry, SkillRegistry, ToolPolicyDecision, ToolPolicyVer
21
21
  import type { AgentMode } from '../agent/agent-mode.js';
22
22
  import type { CliConfig } from '../config/schema.js';
23
23
  import type { TeamHumanQueue } from './human-queue.js';
24
- import { type SubagentAgentFactory } from './subagent.js';
24
+ import { type SubagentAgentFactory, type SubagentDeps } from './subagent.js';
25
25
  import type { DispatchOutcome, SubagentSpec, TeamEventListener, TeamSnapshot } from './types.js';
26
26
  export interface TeamRuntimeDeps {
27
27
  /** Read LIVE: a settings-screen model or key edit must reach the next dispatch. */
@@ -42,6 +42,12 @@ export interface TeamRuntimeDeps {
42
42
  humanQueue?: TeamHumanQueue;
43
43
  /** Test seam (§8.1): a stub factory keeps the whole scheduler off the network. */
44
44
  agentFactory?: SubagentAgentFactory;
45
+ /**
46
+ * Per-tier model resolution (fast-model-tier §3.4). Passed straight through to
47
+ * `SubagentDeps`; absent means every child runs on the session's model, which
48
+ * is the pre-feature behaviour.
49
+ */
50
+ resolveTier?: SubagentDeps['resolveTier'];
45
51
  }
46
52
  export declare class TeamRuntime {
47
53
  private readonly deps;
@@ -75,7 +81,12 @@ export declare class TeamRuntime {
75
81
  isBusy(): boolean;
76
82
  /** The live roster, for `/team` status. `null` when nothing is dispatching. */
77
83
  snapshot(): TeamSnapshot | null;
78
- dispatch(specs: SubagentSpec[], requested: number, signal?: AbortSignal): Promise<DispatchOutcome>;
84
+ dispatch(specs: SubagentSpec[], requested: number, signal?: AbortSignal,
85
+ /** Carried through from the normalizer so the report can state it (§3.4 /
86
+ * R-6). Optional, so every existing caller compiles unchanged. */
87
+ extra?: {
88
+ downgraded?: number;
89
+ }): Promise<DispatchOutcome>;
79
90
  /** One slot of the pool: take the next index until the list is exhausted. */
80
91
  private worker;
81
92
  /**
@@ -65,7 +65,10 @@ export class TeamRuntime {
65
65
  // -------------------------------------------------------------------------
66
66
  // Dispatch
67
67
  // -------------------------------------------------------------------------
68
- async dispatch(specs, requested, signal) {
68
+ async dispatch(specs, requested, signal,
69
+ /** Carried through from the normalizer so the report can state it (§3.4 /
70
+ * R-6). Optional, so every existing caller compiles unchanged. */
71
+ extra = {}) {
69
72
  this.counter += 1;
70
73
  const dispatchId = `d${this.counter}`;
71
74
  const config = this.deps.getConfig();
@@ -122,6 +125,7 @@ export class TeamRuntime {
122
125
  confirmTools: config.confirmTools,
123
126
  ...(this.deps.humanQueue ? { humanQueue: this.deps.humanQueue } : {}),
124
127
  agentFactory: this.deps.agentFactory ?? defaultSubagentFactory,
128
+ ...(this.deps.resolveTier ? { resolveTier: this.deps.resolveTier } : {}),
125
129
  };
126
130
  // Hoisted into a factory so a retry can build a fresh child with IDENTICAL
127
131
  // wiring (F-4). Replacing the child rather than re-prompting it is the only
@@ -130,7 +134,11 @@ export class TeamRuntime {
130
134
  // show the child its brief twice.
131
135
  const makeHandle = (spec) => createSubagent(spec, subDeps, {
132
136
  onUpdate: (run) => this.publish(dispatchId, run),
133
- onUsage: (label, usage) => this.emit({ type: 'usage', dispatchId, label, usage }),
137
+ // `tier` rides along so `App.tsx` can pick the RIGHT cost table for this
138
+ // event rather than always using the lead's (§3.6 / R-7). Taken from the
139
+ // SPEC, which the normalizer already downgraded when the tier was
140
+ // unavailable, so it names the model that actually billed.
141
+ onUsage: (label, usage) => this.emit({ type: 'usage', dispatchId, label, usage, tier: spec.tier }),
134
142
  });
135
143
  // ASSIGNMENT, not a declaration: a `const` here would shadow the `let` above
136
144
  // the bus, `canSend` would see the empty array forever, and every peer would
@@ -174,6 +182,24 @@ export class TeamRuntime {
174
182
  signal?.removeEventListener('abort', onAbort);
175
183
  for (const handle of handles)
176
184
  handle.unsubscribe();
185
+ // SUMMED PER TIER (fast-model-tier §3.4 / R-7). One total priced at the
186
+ // lead's table would over-report a Haiku child under a Sonnet lead by
187
+ // roughly an order of magnitude — and the whole justification for delegating
188
+ // to a cheaper model is a number in the report this feeds.
189
+ // `main` IS THE COMPLEMENT OF `fast`, not an equality test. `SubagentRun.tier`
190
+ // is populated by `createSubagent` from the spec, but a hand-built run in a
191
+ // test — or a run restored from a session file written before this field
192
+ // existed — has it `undefined`, and `=== 'main'` would silently drop that
193
+ // child's spend from the total. Under-reporting is the one failure this
194
+ // split exists to prevent, so the default direction has to be "counted".
195
+ const sumTier = (tier) => handles
196
+ .filter((h) => (tier === 'fast' ? h.run.tier === 'fast' : h.run.tier !== 'fast'))
197
+ .reduce((acc, h) => ({
198
+ inputTokens: acc.inputTokens + h.run.usage.inputTokens,
199
+ outputTokens: acc.outputTokens + h.run.usage.outputTokens,
200
+ }), { inputTokens: 0, outputTokens: 0 });
201
+ const fastUsage = sumTier('fast');
202
+ const ranFast = handles.some((h) => h.run.tier === 'fast');
177
203
  const outcome = {
178
204
  dispatchId,
179
205
  runs: handles.map((h) => ({ ...h.run, filesTouched: [...h.run.filesTouched] })),
@@ -182,10 +208,11 @@ export class TeamRuntime {
182
208
  endedAt: Date.now(),
183
209
  aborted: this.aborted,
184
210
  leadMail: bus.leadMail(),
185
- usage: handles.reduce((acc, h) => ({
186
- inputTokens: acc.inputTokens + h.run.usage.inputTokens,
187
- outputTokens: acc.outputTokens + h.run.usage.outputTokens,
188
- }), { inputTokens: 0, outputTokens: 0 }),
211
+ usage: sumTier('main'),
212
+ // ABSENT when no fast child ran, which is what keeps a default session's
213
+ // outcome shape byte-identical to the pre-feature one.
214
+ ...(ranFast ? { fastUsage } : {}),
215
+ ...(extra.downgraded ? { downgraded: extra.downgraded } : {}),
189
216
  };
190
217
  this.live = [];
191
218
  this.bus = null;
@@ -10,9 +10,10 @@
10
10
  * them (§3.11 / R-6). What a child does NOT get is spelled out below, and each
11
11
  * omission has a reason that is a correctness boundary rather than caution.
12
12
  */
13
- import { type AgentConfig, type AgentEvent, type AgentTool, type ProviderRegistry, type SkillRegistry, type TokenUsage, type ToolPolicyDecision, type ToolPolicyVerdict } from '@aragon-agent/core';
13
+ import { type AgentConfig, type AgentEvent, type AgentTool, type ModelRef, type ProviderRegistry, type SkillRegistry, type ThinkingLevel, type TokenUsage, type ToolPolicyDecision, type ToolPolicyVerdict } from '@aragon-agent/core';
14
14
  import type { AgentMode } from '../agent/agent-mode.js';
15
15
  import type { CliConfig } from '../config/schema.js';
16
+ import type { FastTierName } from '../fast/types.js';
16
17
  import { TeamBus } from './bus.js';
17
18
  import type { TeamHumanQueue, WatchdogPausable } from './human-queue.js';
18
19
  import type { SubagentRun, SubagentSpec } from './types.js';
@@ -61,6 +62,23 @@ export interface SubagentDeps {
61
62
  /** Present only when `--confirm` is on AND a human channel exists. */
62
63
  humanQueue?: TeamHumanQueue;
63
64
  agentFactory: SubagentAgentFactory;
65
+ /**
66
+ * Which model and thinking level a tier runs on (fast-model-tier §3.4).
67
+ *
68
+ * OPTIONAL, defaulting to the session's own model, so every existing caller
69
+ * and test compiles unchanged and a `--no-fast` session builds children
70
+ * exactly as it always did.
71
+ *
72
+ * `resolveTier('fast')` RETURNS THE MAIN TIER when the fast tier is not
73
+ * available, which is what makes the two guards agree BY CONSTRUCTION: the
74
+ * normalizer can never produce a `tier: 'fast'` spec this factory would
75
+ * refuse, and this factory can never build a child against a `ModelRef` the
76
+ * tier stopped resolving twenty minutes ago (RV-3 / R-14).
77
+ */
78
+ resolveTier?: (tier: FastTierName) => {
79
+ ref: ModelRef;
80
+ thinkingLevel: ThinkingLevel;
81
+ };
64
82
  }
65
83
  export interface SubagentHooks {
66
84
  /** Coalesced at the source (§5.2); phase transitions always pass. */
@@ -137,6 +137,9 @@ export function createSubagent(spec, deps, hooks) {
137
137
  const run = {
138
138
  label: spec.label,
139
139
  description: spec.description,
140
+ // FROM THE SPEC, which the normalizer already downgraded when the tier was
141
+ // unavailable — so this is what RAN, not what was asked for (§3.4).
142
+ tier: spec.tier,
140
143
  phase: 'queued',
141
144
  turns: 0,
142
145
  toolCalls: 0,
@@ -151,11 +154,31 @@ export function createSubagent(spec, deps, hooks) {
151
154
  let agentRef = null;
152
155
  const getAgent = () => agentRef;
153
156
  const tools = buildSubagentTools(spec, deps, getAgent);
154
- const model = {
155
- providerId: deps.config.provider,
156
- modelId: deps.config.model,
157
- ...(deps.config.baseUrl ? { baseUrl: deps.config.baseUrl } : {}),
158
- };
157
+ // THE TWO HARDCODED CONFIG READS THAT USED TO LIVE HERE NOW ROUTE THROUGH ONE
158
+ // RESOLVER (fast-model-tier §3.4). The fallback reproduces them exactly, so a
159
+ // caller that passes no `resolveTier` builds a byte-identical child.
160
+ //
161
+ // `thinkingLevel` is PER TIER and defaults to `'off'` for the fast tier
162
+ // (D-13): a "fast" model asked to think for 32 768 tokens is not fast, and
163
+ // inheriting the session's `xhigh` into a mechanical file-scan child would be
164
+ // the most expensive possible reading of the word.
165
+ //
166
+ // `maxTokens` is deliberately NOT per tier (RV-15). A child inherits
167
+ // `deps.config.maxTokens` below, which is an ambition sized for the main
168
+ // model; the adapter clamps it down to whatever the fast model actually
169
+ // accepts (`llm/provider.ts:44-52`), so a large inherited value degrades to
170
+ // the right number rather than to an HTTP 400.
171
+ const resolved = deps.resolveTier
172
+ ? deps.resolveTier(spec.tier)
173
+ : {
174
+ ref: {
175
+ providerId: deps.config.provider,
176
+ modelId: deps.config.model,
177
+ ...(deps.config.baseUrl ? { baseUrl: deps.config.baseUrl } : {}),
178
+ },
179
+ thinkingLevel: deps.config.thinkingLevel,
180
+ };
181
+ const model = resolved.ref;
159
182
  const agent = deps.agentFactory({
160
183
  systemPrompt: buildSystemPrompt({
161
184
  cwd: deps.getCwd(),
@@ -174,7 +197,7 @@ export function createSubagent(spec, deps, hooks) {
174
197
  }),
175
198
  model,
176
199
  tools,
177
- thinkingLevel: deps.config.thinkingLevel,
200
+ thinkingLevel: resolved.thinkingLevel,
178
201
  // The LEAD's registry instance, REUSED (P2-2). It holds adapter state, and
179
202
  // constructing five of them per dispatch is pure waste.
180
203
  providerRegistry: deps.providerRegistry,
@@ -194,18 +217,34 @@ export function createSubagent(spec, deps, hooks) {
194
217
  agentRef = agent;
195
218
  let lastEmit = 0;
196
219
  let lastPhase = run.phase;
220
+ /**
221
+ * The retry projection as it was last PUBLISHED (§6.10).
222
+ *
223
+ * A retry transition has to pass the throttle for the same reason a phase
224
+ * transition does, and it is not covered by the phase check: a child entering
225
+ * backoff stays `thinking`, so `retry_scheduled` arriving within a throttle
226
+ * window of the previous token would be COALESCED AWAY — and the next event is
227
+ * up to thirty seconds later, which is exactly the interval the row exists to
228
+ * explain. The row would then say `thinking` for the whole wait.
229
+ */
230
+ let lastRetryKey = '';
197
231
  // The tail of the text the child is writing right now (F-1). Bounded on every
198
232
  // append: a long turn would otherwise grow this without limit, and it is the
199
233
  // only per-token allocation this feature adds.
200
234
  let textTail = '';
201
235
  const emit = () => {
202
236
  const now = Date.now();
203
- // Phase transitions always pass; everything else is coalesced. Five children
204
- // streaming tokens must not push five React renders per token, and the
205
- // streaming coalescer in `App.tsx` protects the transcript, not this path.
206
- if (run.phase === lastPhase && now - lastEmit < TEAM_LIMITS.agentUpdateThrottleMs)
237
+ const retryKey = run.retry ? `${run.retry.attempt}/${run.retry.maxRetries}` : '';
238
+ // Phase and retry transitions always pass; everything else is coalesced. Five
239
+ // children streaming tokens must not push five React renders per token, and
240
+ // the streaming coalescer in `App.tsx` protects the transcript, not this path.
241
+ if (run.phase === lastPhase &&
242
+ retryKey === lastRetryKey &&
243
+ now - lastEmit < TEAM_LIMITS.agentUpdateThrottleMs) {
207
244
  return;
245
+ }
208
246
  lastPhase = run.phase;
247
+ lastRetryKey = retryKey;
209
248
  lastEmit = now;
210
249
  // Exactly one sanitize per PUBLISHED frame rather than one per token (P2-2):
211
250
  // `emit()` throws away all but one result per throttle window, so two
@@ -238,6 +277,9 @@ export function createSubagent(spec, deps, hooks) {
238
277
  break;
239
278
  case 'turn_end': {
240
279
  run.turns += 1;
280
+ // A completed turn means whatever it retried, it recovered (§6.10). The
281
+ // row goes back to reporting its phase.
282
+ run.retry = undefined;
241
283
  run.usage = {
242
284
  inputTokens: run.usage.inputTokens + event.usage.inputTokens,
243
285
  outputTokens: run.usage.outputTokens + event.usage.outputTokens,
@@ -288,6 +330,26 @@ export function createSubagent(spec, deps, hooks) {
288
330
  if (event.streamEvent.type === 'error') {
289
331
  run.error = formatStreamError(event.streamEvent.error);
290
332
  run.retryable = isRetryableStreamError(event.streamEvent.error);
333
+ // The ladder is over: an `error` reaching a subscriber means `withRetry`
334
+ // stopped retrying. Leaving the projection set would freeze the row at
335
+ // `retrying 10/10` on top of a failure (§6.10).
336
+ run.retry = undefined;
337
+ }
338
+ else if (event.streamEvent.type === 'retry_scheduled') {
339
+ // SET, and the phase is deliberately LEFT ALONE: the child is still
340
+ // `thinking` as far as the scheduler is concerned, and rewriting the
341
+ // phase would put a retry into `SubagentPhase` — a union the report, the
342
+ // panel and `canSend` all read for a different question.
343
+ run.retry = {
344
+ attempt: event.streamEvent.attempt,
345
+ maxRetries: event.streamEvent.maxRetries,
346
+ };
347
+ }
348
+ else if (event.streamEvent.type === 'retry_attempt') {
349
+ run.retry = {
350
+ attempt: event.streamEvent.attempt,
351
+ maxRetries: event.streamEvent.maxRetries,
352
+ };
291
353
  }
292
354
  else if (event.streamEvent.type === 'text_delta') {
293
355
  // O(1) per delta: one bounded concat and one bounded slice, and