@aragon-agent/cli 0.6.23 → 0.6.24

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 (42) hide show
  1. package/README.md +20 -2
  2. package/dist/agent/controller.d.ts +11 -0
  3. package/dist/agent/controller.js +33 -3
  4. package/dist/agent/model-profile-settings.js +29 -2
  5. package/dist/commands/builtins.js +87 -3
  6. package/dist/compaction/child.d.ts +15 -0
  7. package/dist/compaction/child.js +14 -3
  8. package/dist/compaction/compactor.js +1 -0
  9. package/dist/compaction/context-command.js +10 -1
  10. package/dist/compaction/meter.d.ts +9 -0
  11. package/dist/compaction/meter.js +16 -1
  12. package/dist/compaction/pressure.d.ts +7 -0
  13. package/dist/compaction/pressure.js +5 -0
  14. package/dist/compaction/types.d.ts +11 -0
  15. package/dist/compaction/wiring.js +1 -0
  16. package/dist/config/cli-commands.d.ts +1 -1
  17. package/dist/config/cli-commands.js +12 -0
  18. package/dist/config/load.js +24 -6
  19. package/dist/config/model-profile-resolution.d.ts +8 -0
  20. package/dist/config/model-profile-resolution.js +13 -6
  21. package/dist/config/model-profile-store.d.ts +4 -2
  22. package/dist/config/model-profiles.d.ts +13 -0
  23. package/dist/config/model-profiles.js +18 -2
  24. package/dist/config/schema.d.ts +61 -0
  25. package/dist/config/schema.js +36 -0
  26. package/dist/team/model.d.ts +88 -0
  27. package/dist/team/model.js +136 -0
  28. package/dist/team/report.d.ts +11 -0
  29. package/dist/team/report.js +24 -1
  30. package/dist/team/subagent.js +37 -21
  31. package/dist/team/task-tool.d.ts +25 -6
  32. package/dist/team/task-tool.js +15 -9
  33. package/dist/ui/App.js +5 -0
  34. package/dist/ui/overlays/ModelProfileEditor.js +18 -4
  35. package/dist/ui/overlays/ModelProfilePicker.d.ts +14 -2
  36. package/dist/ui/overlays/ModelProfilePicker.js +51 -10
  37. package/dist/ui/overlays/QuestionOverlay.js +47 -6
  38. package/dist/ui/overlays/SettingsScreen.d.ts +9 -0
  39. package/dist/ui/overlays/SettingsScreen.js +119 -17
  40. package/dist/ui/overlays/model-profile-state.d.ts +53 -4
  41. package/dist/ui/overlays/model-profile-state.js +111 -14
  42. package/package.json +2 -2
@@ -411,33 +411,44 @@ function resolveMaxTokens(flags, env, file) {
411
411
  * `null`, and `null` is exactly the value being looked for. NO CLI FLAG feeds
412
412
  * this - it is a per-installation correction for a model table entry, not a
413
413
  * per-invocation choice, so `config set` and the environment variable are the
414
- * two surfaces the design names.
414
+ * two surfaces the design names. The model profile bound as Main is the third
415
+ * (profile-context-window): its window arrives already merged into the `file`
416
+ * layer by `projectModelProfiles`, and `profileOwned` names it.
417
+ *
418
+ * THE ORIGIN CONTRACT (profile-context-window §6.1, shared verbatim with the
419
+ * save transaction): `origin !== undefined` IF AND ONLY IF `value !== null`.
420
+ * An explicit AUTO - profile set to auto, `config set contextWindow auto`,
421
+ * `ARAGON_CONTEXT_WINDOW=auto` - returns `{ value: null }` and NO origin,
422
+ * because "the user asserted this denominator" and "the user declared AUTO"
423
+ * are different facts and the meter's override branch only sees the first.
415
424
  */
416
- function resolveContextWindow(env, file) {
425
+ function resolveContextWindow(env, file, profileOwned) {
417
426
  const layers = [
418
427
  {
419
428
  name: 'ARAGON_CONTEXT_WINDOW',
420
429
  value: hasOwn(env, 'contextWindow') ? env.contextWindow : undefined,
430
+ origin: 'env',
421
431
  },
422
432
  {
423
433
  name: 'config.json contextWindow',
424
434
  value: hasOwn(file, 'contextWindow') ? file.contextWindow : undefined,
435
+ origin: profileOwned ? 'profile' : 'config',
425
436
  },
426
437
  ];
427
438
  for (const layer of layers) {
428
439
  if (layer.value === undefined)
429
440
  continue; // this layer said nothing
430
441
  if (layer.value === null || isAutoToken(layer.value))
431
- return null; // AUTO
442
+ return { value: null }; // AUTO
432
443
  const n = clampContextWindow(layer.value, null);
433
444
  if (n !== null)
434
- return n;
445
+ return { value: n, origin: layer.origin };
435
446
  getLogger().warn('config', 'context_window_unusable', {
436
447
  source: layer.name,
437
448
  value: String(layer.value),
438
449
  });
439
450
  }
440
- return DEFAULT_CONFIG.contextWindow;
451
+ return { value: DEFAULT_CONFIG.contextWindow };
441
452
  }
442
453
  /**
443
454
  * Rewrite the cli<=0.6.16 persisted team ceiling defaults to 0 and persist the
@@ -513,7 +524,8 @@ export function loadConfig(flags = {}, options = {}) {
513
524
  const liveToolOutput = pick(flags.liveToolOutput, env.partial.liveToolOutput, file.liveToolOutput) ??
514
525
  DEFAULT_CONFIG.liveToolOutput;
515
526
  const maxTokens = resolveMaxTokens(flags, env.partial, file);
516
- const contextWindow = resolveContextWindow(env.partial, file);
527
+ const resolvedWindow = resolveContextWindow(env.partial, file, projection.mainOwnsContextWindow);
528
+ const contextWindow = resolvedWindow.value;
517
529
  // `clampTheme` is also where the v0.3.0 `dark` name is migrated to `cool`,
518
530
  // so reading a legacy config file needs no extra step here (§4.5 / R-4).
519
531
  const theme = clampTheme(pick(flags.theme, env.partial.theme, file.theme), DEFAULT_CONFIG.theme);
@@ -609,6 +621,12 @@ export function loadConfig(flags = {}, options = {}) {
609
621
  liveToolOutput,
610
622
  maxTokens,
611
623
  contextWindow,
624
+ // CONDITIONAL SPREAD so a feature-off config object stays byte-identical
625
+ // (invariant 6): with no override there is no origin KEY, not an undefined
626
+ // one, and every `toEqual` on a default config keeps passing untouched.
627
+ ...(resolvedWindow.origin !== undefined
628
+ ? { contextWindowOrigin: resolvedWindow.origin }
629
+ : {}),
612
630
  theme,
613
631
  reducedMotion,
614
632
  exitTranscript: flags.exitTranscript !== undefined
@@ -4,6 +4,14 @@ export interface ProfileProjection {
4
4
  file: Partial<PersistedConfig>;
5
5
  state: ModelProfilesRuntime;
6
6
  issues: ProfileValidationIssue[];
7
+ /**
8
+ * True when a valid library has Main bound, so the bound profile OWNS the
9
+ * context-window denominator (profile-context-window tradeoff B). THE ONE
10
+ * AUTHORITY for "does the profile decide the window" - load.ts and the save
11
+ * transaction both read it rather than comparing numbers, which would lie on
12
+ * a coincidental match.
13
+ */
14
+ mainOwnsContextWindow: boolean;
7
15
  }
8
16
  export interface RoleKeyInput {
9
17
  config: CliConfig;
@@ -9,8 +9,9 @@ export function projectModelProfiles(file) {
9
9
  const issues = validateModelProfiles(file.modelProfiles);
10
10
  const state = emptyModelProfileState(issues.length > 0);
11
11
  const projected = { ...file };
12
- if (issues.length || !file.modelProfiles)
13
- return { file: projected, state, issues };
12
+ if (issues.length || !file.modelProfiles) {
13
+ return { file: projected, state, issues, mainOwnsContextWindow: false };
14
+ }
14
15
  const library = normalizeModelProfiles(file.modelProfiles);
15
16
  projected.modelProfiles = library;
16
17
  for (const role of ['main', 'fast']) {
@@ -20,13 +21,19 @@ export function projectModelProfiles(file) {
20
21
  state[role] = { selectedId: entry.id, appliedId: entry.id,
21
22
  overriddenFields: [], invalid: false };
22
23
  const connection = { provider: entry.provider, model: entry.model, baseUrl: entry.baseUrl };
23
- if (role === 'main')
24
- Object.assign(projected, connection);
25
- else
24
+ if (role === 'main') {
25
+ // The profile owns the denominator: project BOTH a number and null
26
+ // (null = AUTO), so "profile set auto" stays distinguishable from
27
+ // "no profile bound" (profile-context-window tradeoff B).
28
+ Object.assign(projected, connection, { contextWindow: entry.contextWindow });
29
+ }
30
+ else {
26
31
  projected.fast = { ...DEFAULT_CONFIG.fast, ...file.fast, ...connection,
27
32
  baseUrl: entry.baseUrl ?? '' };
33
+ }
28
34
  }
29
- return { file: projected, state, issues };
35
+ return { file: projected, state, issues,
36
+ mainOwnsContextWindow: state.main.appliedId !== null };
30
37
  }
31
38
  /** Derive binding status from current connections, including session/model overrides. */
32
39
  export function resolveModelProfileState(config, profiles = config.modelProfiles, invalid = false) {
@@ -1,9 +1,11 @@
1
1
  import { type ModelProfilesConfig, type ModelRole, type ProfileValidationIssue } from './model-profiles.js';
2
- import type { CliConfig, CompactionConfig, FastConfig, LogConfig, PersistedConfig } from './schema.js';
3
- export interface ModelSettingsPatch extends Partial<Omit<PersistedConfig, 'modelProfiles' | 'fast' | 'compaction' | 'log'>> {
2
+ import type { CliConfig, CompactionConfig, FastConfig, LogConfig, PersistedConfig, TeamConfig } from './schema.js';
3
+ export interface ModelSettingsPatch extends Partial<Omit<PersistedConfig, 'modelProfiles' | 'fast' | 'compaction' | 'log' | 'team'>> {
4
4
  fast?: Partial<FastConfig>;
5
5
  compaction?: Partial<CompactionConfig>;
6
6
  log?: Partial<LogConfig>;
7
+ /** The subagent-model picker writes here (subagent-model D-5/I-5): draft-only until Save. */
8
+ team?: Partial<TeamConfig>;
7
9
  }
8
10
  export interface ModelSettingsDraft {
9
11
  profiles?: ModelProfilesConfig;
@@ -6,6 +6,8 @@ export interface ModelProfile {
6
6
  model: string;
7
7
  baseUrl: string | null;
8
8
  apiKey: string | null;
9
+ /** The context window of this model+endpoint; null = AUTO (profile-context-window 5.1). */
10
+ contextWindow: number | null;
9
11
  }
10
12
  export interface ModelProfilesConfig {
11
13
  version: 1;
@@ -34,6 +36,17 @@ export declare function emptyModelProfiles(): ModelProfilesConfig;
34
36
  export declare function emptyModelProfileState(invalid?: boolean): ModelProfilesRuntime;
35
37
  /** Validate untrusted input; diagnostics never include supplied values. */
36
38
  export declare function validateModelProfiles(value: unknown): ProfileValidationIssue[];
39
+ /**
40
+ * Disk-tolerance gate for a profile-level context window (profile-context-window 5.3).
41
+ *
42
+ * THE SAME TRI-STATE DISCIPLINE as store.ts normalizeContextWindow, kept out of
43
+ * store.ts on purpose: this file already imports schema.js and store's sibling
44
+ * model-profile-store.ts already imports this one, so placing it here avoids
45
+ * widening the existing store <-> model-profile-store cycle. A garbage value on
46
+ * a hand-edited file degrades to AUTO; it NEVER invalidates the whole library
47
+ * (invariant 4 - one bad number must not drop the user's credentials).
48
+ */
49
+ export declare function normalizeProfileContextWindow(value: unknown): number | null;
37
50
  /** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
38
51
  export declare function normalizeModelProfiles(value: unknown): ModelProfilesConfig;
39
52
  /** Create an entry with a stable UUID and a frozen default name. */
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { ADAPTER_PROVIDERS } from './schema.js';
2
+ import { ADAPTER_PROVIDERS, clampContextWindow, isAutoToken } from './schema.js';
3
3
  const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
4
4
  /** Return a fresh empty library without initiating a disk migration. */
5
5
  export function emptyModelProfiles() {
@@ -91,6 +91,21 @@ export function validateModelProfiles(value) {
91
91
  }
92
92
  return issues;
93
93
  }
94
+ /**
95
+ * Disk-tolerance gate for a profile-level context window (profile-context-window 5.3).
96
+ *
97
+ * THE SAME TRI-STATE DISCIPLINE as store.ts normalizeContextWindow, kept out of
98
+ * store.ts on purpose: this file already imports schema.js and store's sibling
99
+ * model-profile-store.ts already imports this one, so placing it here avoids
100
+ * widening the existing store <-> model-profile-store cycle. A garbage value on
101
+ * a hand-edited file degrades to AUTO; it NEVER invalidates the whole library
102
+ * (invariant 4 - one bad number must not drop the user's credentials).
103
+ */
104
+ export function normalizeProfileContextWindow(value) {
105
+ if (value === undefined || value === null || isAutoToken(value))
106
+ return null;
107
+ return clampContextWindow(value, null);
108
+ }
94
109
  /** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
95
110
  export function normalizeModelProfiles(value) {
96
111
  if (validateModelProfiles(value).length)
@@ -102,7 +117,8 @@ export function normalizeModelProfiles(value) {
102
117
  entries: library.entries.map((entry) => ({ id: entry.id, name: entry.name.trim()
103
118
  || [...entry.model.trim()].slice(0, 80).join(''), provider: entry.provider,
104
119
  model: entry.model.trim(), baseUrl: entry.baseUrl?.trim() || null,
105
- apiKey: entry.apiKey?.trim() || null })) };
120
+ apiKey: entry.apiKey?.trim() || null,
121
+ contextWindow: normalizeProfileContextWindow(entry.contextWindow) })) };
106
122
  }
107
123
  /** Create an entry with a stable UUID and a frozen default name. */
108
124
  export function createModelProfile(input) {
@@ -99,6 +99,35 @@ export declare const MAX_CONTEXT_WINDOW = 5000000;
99
99
  * this deliberately copies its shape rather than inventing a second convention.
100
100
  */
101
101
  export declare function clampContextWindow(v: unknown, fallback: number | null): number | null;
102
+ /**
103
+ * Where the effective `contextWindow` override came from
104
+ * (profile-context-window §5.2).
105
+ *
106
+ * A CLI-LOCAL RUNTIME FACT: never persisted, never promoted into Core's event
107
+ * shapes. It exists so `/context` can name WHICH surface supplied a
108
+ * denominator the user asserted - the environment, the legacy top-level key,
109
+ * or the model profile bound as Main.
110
+ */
111
+ export type ContextWindowOrigin = 'env' | 'config' | 'profile';
112
+ /**
113
+ * Parse text typed into the settings screen or the profile editor for a
114
+ * context window (profile-context-window §5.3).
115
+ *
116
+ * THE MIRROR OF `parseMaxTokensInput` BELOW, down to the tri-state shape: it
117
+ * distinguishes AUTO from "the user typed nonsense", which `clampContextWindow`
118
+ * cannot - a typo must change NOTHING rather than silently discard the number
119
+ * the user meant to set. `''` / `'auto'` / `'0'` are AUTO (`isAutoToken`);
120
+ * a digit run is clamped through THE single gate; anything else is invalid.
121
+ */
122
+ export declare function parseContextWindowInput(raw: string): {
123
+ kind: 'auto';
124
+ } | {
125
+ kind: 'value';
126
+ value: number;
127
+ clamped: boolean;
128
+ } | {
129
+ kind: 'invalid';
130
+ };
102
131
  /**
103
132
  * Entries kept in `ViewState` at all (tui-render-performance L1 / R5).
104
133
  *
@@ -356,6 +385,26 @@ export interface TeamConfig {
356
385
  * meaningful while `team.overseer` is true.
357
386
  */
358
387
  overseerIntervalMs: number;
388
+ /**
389
+ * Which model a team child runs on (subagent-model-and-question-controls
390
+ * D-2). `'main'` = the lead's own connection - the default and the
391
+ * historical parity behavior; `'fast'` = the fast tier, resolved per
392
+ * dispatch through `resolveFastTier`; `'profile'` = the entry named by
393
+ * `team.modelProfileId`.
394
+ *
395
+ * A USER-OWNED SETTING, NEVER A TOOL PARAMETER (D-1): `normalizeSubagentSpecs`
396
+ * keeps silently dropping a stale `model` key, so the MODEL cannot downgrade
397
+ * its own delegation. This key is the one explicit, visible, reversible
398
+ * switch the human owns.
399
+ */
400
+ model: 'main' | 'fast' | 'profile';
401
+ /**
402
+ * The profile entry a `model: 'profile'` dispatch binds to; `null` = not
403
+ * bound. IGNORED by the other modes. Existence is NOT validated here - a
404
+ * dangling id degrades to the main model with a disclosed reason at
405
+ * resolution time (D-4), so deleting a profile can never break a dispatch.
406
+ */
407
+ modelProfileId: string | null;
359
408
  }
360
409
  /**
361
410
  * The requirement's ceiling on the fan-out (R-g).
@@ -967,6 +1016,18 @@ export interface CliConfig {
967
1016
  * would parse and be discarded, with nothing failing.
968
1017
  */
969
1018
  contextWindow: number | null;
1019
+ /**
1020
+ * WHERE the override above came from, or `undefined` when there is none
1021
+ * (profile-context-window §5.2). RUNTIME-ONLY, never persisted.
1022
+ *
1023
+ * THE CONTRACT IS AN IFF: `contextWindowOrigin !== undefined` exactly when
1024
+ * `contextWindow !== null`. An explicit AUTO (profile set to auto,
1025
+ * `config set contextWindow auto`, `ARAGON_CONTEXT_WINDOW=auto`) has value
1026
+ * null, so its origin is undefined - "the user asserted this denominator"
1027
+ * and "the user declared AUTO" are different facts, and the meter's
1028
+ * override branch only ever sees the first.
1029
+ */
1030
+ contextWindowOrigin?: ContextWindowOrigin;
970
1031
  theme: ThemeName;
971
1032
  reducedMotion: boolean;
972
1033
  /** Replay a plain-text summary on exit (full-screen only — see §4.4). */
@@ -148,6 +148,27 @@ export function clampContextWindow(v, fallback) {
148
148
  return fallback;
149
149
  return Math.min(MAX_CONTEXT_WINDOW, Math.max(MIN_CONTEXT_WINDOW, Math.floor(n)));
150
150
  }
151
+ /**
152
+ * Parse text typed into the settings screen or the profile editor for a
153
+ * context window (profile-context-window §5.3).
154
+ *
155
+ * THE MIRROR OF `parseMaxTokensInput` BELOW, down to the tri-state shape: it
156
+ * distinguishes AUTO from "the user typed nonsense", which `clampContextWindow`
157
+ * cannot - a typo must change NOTHING rather than silently discard the number
158
+ * the user meant to set. `''` / `'auto'` / `'0'` are AUTO (`isAutoToken`);
159
+ * a digit run is clamped through THE single gate; anything else is invalid.
160
+ */
161
+ export function parseContextWindowInput(raw) {
162
+ const trimmed = String(raw ?? '').trim();
163
+ if (isAutoToken(trimmed))
164
+ return { kind: 'auto' };
165
+ if (!/^\d+$/.test(trimmed))
166
+ return { kind: 'invalid' };
167
+ const value = clampContextWindow(trimmed, null);
168
+ if (value === null)
169
+ return { kind: 'invalid' };
170
+ return { kind: 'value', value, clamped: value !== Number.parseInt(trimmed, 10) };
171
+ }
151
172
  /**
152
173
  * Entries kept in `ViewState` at all (tui-render-performance L1 / R5).
153
174
  *
@@ -457,6 +478,10 @@ export const DEFAULT_TEAM_CONFIG = {
457
478
  overseer: true,
458
479
  // 0 = the structural cadence default (300 s). See the field docstring.
459
480
  overseerIntervalMs: 0,
481
+ // The parity default: children run the lead's own model until the user
482
+ // says otherwise (AC-1 - an upgraded config behaves exactly as before).
483
+ model: 'main',
484
+ modelProfileId: null,
460
485
  };
461
486
  const SUBAGENTS_RANGE = { min: 1, max: HARD_MAX_SUBAGENTS };
462
487
  const CONCURRENT_RANGE = { min: 1, max: HARD_MAX_SUBAGENTS };
@@ -490,6 +515,17 @@ export function clampTeamConfig(raw) {
490
515
  maxTurnsPerSubagent: clampIntAllowingZero(src.maxTurnsPerSubagent, DEFAULT_TEAM_CONFIG.maxTurnsPerSubagent, TURNS_RANGE),
491
516
  overseer: bool(src.overseer, DEFAULT_TEAM_CONFIG.overseer),
492
517
  overseerIntervalMs: clampIntAllowingZero(src.overseerIntervalMs, DEFAULT_TEAM_CONFIG.overseerIntervalMs, OVERSEER_INTERVAL_RANGE),
518
+ // typo-changes-nothing, the discipline every enum clamp here follows: a
519
+ // hand-edited `model: 'fats'` resolves to 'main' (the parity default)
520
+ // rather than invalidating the file or refusing to launch. A bad
521
+ // `modelProfileId` (wrong type, or over the 128-codepoint budget
522
+ // `model-profiles.ts::validText` defines) is nulled the same way;
523
+ // reference EXISTENCE stays a resolution-time concern (D-4).
524
+ model: src.model === 'fast' || src.model === 'profile' ? src.model : 'main',
525
+ modelProfileId: typeof src.modelProfileId === 'string' && src.modelProfileId.length > 0
526
+ && [...src.modelProfileId].length <= 128
527
+ ? src.modelProfileId
528
+ : null,
493
529
  };
494
530
  }
495
531
  /**
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `resolveSubagentModel` — THE single authority on which model a team child
3
+ * runs (subagent-model-and-question-controls §3.3).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * EVERY CONSUMER READS THE CHILD'S MODEL THROUGH THIS FUNCTION AND NOWHERE
8
+ * ELSE — `createSubagent`, the `task` report header, the settings root row and
9
+ * `/team model` — following the `resolveFastTier` precedent one directory over:
10
+ * four call sites each recomputing "which model do children run on?" from raw
11
+ * config keys is exactly how the report ends up naming a model the children
12
+ * silently fell back from.
13
+ *
14
+ * IT IS RE-RUN ON EVERY DISPATCH AND EVERY RENDER, NOT ONCE (rule 8 / RV-3 of
15
+ * the fast tier, inherited here): `team.model`, the fast tier and the profile
16
+ * library are all LIVE keys a settings save, `/fast off` or `/team model` can
17
+ * rewrite mid-session. A resolution captured at construction would be a
18
+ * resolution describing a session that no longer exists.
19
+ *
20
+ * A CHILD IS NEVER LEFT WITHOUT A MODEL (D-4). Every failure mode — fast tier
21
+ * off, profile deleted, profile key missing — degrades to the MAIN connection
22
+ * with a `fallbackReason`, so a dispatch cannot fail because its model setting
23
+ * went stale. The reason is disclosed, never swallowed: the report header and
24
+ * the settings summary line both render it.
25
+ */
26
+ import type { ModelRef, ThinkingLevel } from '@aragon-agent/core';
27
+ import type { ModelProfile, ModelRole } from '../config/model-profiles.js';
28
+ import type { CliConfig } from '../config/schema.js';
29
+ /** Which model children run on. `main` is the default and the historical behavior. */
30
+ export type SubagentModelMode = 'main' | 'fast' | 'profile';
31
+ /** Why a non-main request degraded to the main model (D-4 disclosure). */
32
+ export type SubagentFallbackReason =
33
+ /** `team.model='fast'` but `resolveFastTier` is not ok (off, unconfigured, no key). */
34
+ 'fast_unavailable'
35
+ /** `modelProfileId` is absent from the library (deleted, or never existed). */
36
+ | 'profile_missing'
37
+ /** The entry exists but carries no key of its own and the shared layer has none. */
38
+ | 'profile_no_key';
39
+ /** In-memory resolution; recomputed per dispatch/render, never persisted (§6.3). */
40
+ export interface SubagentModelResolution {
41
+ /** The user's requested mode, verbatim from config (UI highlight reads this). */
42
+ mode: SubagentModelMode;
43
+ /** The mode actually in effect; `'main'` whenever a fallback fired. */
44
+ effective: SubagentModelMode;
45
+ /** The connection children will use. On fallback, exactly the main connection. */
46
+ ref: ModelRef;
47
+ /** main/profile take `config.thinkingLevel`; fast takes `config.fast.thinkingLevel`. */
48
+ thinkingLevel: ThinkingLevel;
49
+ /** The role controller key resolution uses: main/profile `'main'`, fast `'fast'`. */
50
+ keyRole: ModelRole;
51
+ /** The profile entry a `profile` request hit, dedicated key included; else null. */
52
+ profile: ModelProfile | null;
53
+ /** Present only when `effective !== mode`. */
54
+ fallbackReason?: SubagentFallbackReason;
55
+ }
56
+ /** The `AgentController.hasApiKey` shape, injected so there is one key resolver. */
57
+ export type HasApiKey = (providerId: string, role?: ModelRole) => boolean;
58
+ /** The lead-layer key resolver `resolveSubagentKey` falls back to. */
59
+ export type SharedKeyResolver = (providerId: string, role: ModelRole) => string | undefined;
60
+ /**
61
+ * Resolve the model every child of the NEXT dispatch will run on.
62
+ *
63
+ * Pure, synchronous, no IO — testable as a matrix over config fixtures.
64
+ *
65
+ * @param config The live effective config. Read fresh; never snapshot it.
66
+ * @param hasKey `AgentController.hasApiKey`, which already resolves the
67
+ * config-file key, the provider environment variable and the one-shot
68
+ * `--api-key` override. Injected rather than reimplemented for the reason
69
+ * `resolveFastTier` records: a second key resolver is a second answer to
70
+ * "why does it say no key?".
71
+ */
72
+ export declare function resolveSubagentModel(config: CliConfig, hasKey: HasApiKey): SubagentModelResolution;
73
+ /**
74
+ * The key a CHILD resolves for a provider, profile-aware (§3.3 / I-4).
75
+ *
76
+ * A profile's dedicated key is visible ONLY here: it never reaches env,
77
+ * `config.apiKeys` or a log line. While the resolution's profile is bound and
78
+ * the provider matches it, the entry key takes priority over the shared layer;
79
+ * every other provider (and every non-profile mode) resolves through the
80
+ * lead's own resolver at `resolution.keyRole` — which is what keeps a `fast`
81
+ * child entitled to the fast-role key and a `main` child to the main one.
82
+ *
83
+ * @param config The live config (carried for parity with the resolver pair).
84
+ * @param resolution A fresh `resolveSubagentModel` output.
85
+ * @param providerId The provider the child needs a key for.
86
+ * @param shared The lead-layer resolver (`controller.resolveKey`).
87
+ */
88
+ export declare function resolveSubagentKey(config: CliConfig, resolution: SubagentModelResolution, providerId: string, shared: SharedKeyResolver): string | undefined;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `resolveSubagentModel` — THE single authority on which model a team child
3
+ * runs (subagent-model-and-question-controls §3.3).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * EVERY CONSUMER READS THE CHILD'S MODEL THROUGH THIS FUNCTION AND NOWHERE
8
+ * ELSE — `createSubagent`, the `task` report header, the settings root row and
9
+ * `/team model` — following the `resolveFastTier` precedent one directory over:
10
+ * four call sites each recomputing "which model do children run on?" from raw
11
+ * config keys is exactly how the report ends up naming a model the children
12
+ * silently fell back from.
13
+ *
14
+ * IT IS RE-RUN ON EVERY DISPATCH AND EVERY RENDER, NOT ONCE (rule 8 / RV-3 of
15
+ * the fast tier, inherited here): `team.model`, the fast tier and the profile
16
+ * library are all LIVE keys a settings save, `/fast off` or `/team model` can
17
+ * rewrite mid-session. A resolution captured at construction would be a
18
+ * resolution describing a session that no longer exists.
19
+ *
20
+ * A CHILD IS NEVER LEFT WITHOUT A MODEL (D-4). Every failure mode — fast tier
21
+ * off, profile deleted, profile key missing — degrades to the MAIN connection
22
+ * with a `fallbackReason`, so a dispatch cannot fail because its model setting
23
+ * went stale. The reason is disclosed, never swallowed: the report header and
24
+ * the settings summary line both render it.
25
+ */
26
+ import { resolveFastTier } from '../fast/resolve.js';
27
+ function mainRef(config) {
28
+ return {
29
+ providerId: config.provider,
30
+ modelId: config.model,
31
+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
32
+ };
33
+ }
34
+ function fallbackToMain(config, mode, fallbackReason) {
35
+ // THE ALWAYS-USABLE ANSWER (D-4): whatever the user asked for that could not
36
+ // resolve, the caller still gets a working connection — the same provider,
37
+ // model, base URL and thinking level the lead itself runs on.
38
+ return {
39
+ mode,
40
+ effective: 'main',
41
+ ref: mainRef(config),
42
+ thinkingLevel: config.thinkingLevel,
43
+ keyRole: 'main',
44
+ profile: null,
45
+ fallbackReason,
46
+ };
47
+ }
48
+ /**
49
+ * Resolve the model every child of the NEXT dispatch will run on.
50
+ *
51
+ * Pure, synchronous, no IO — testable as a matrix over config fixtures.
52
+ *
53
+ * @param config The live effective config. Read fresh; never snapshot it.
54
+ * @param hasKey `AgentController.hasApiKey`, which already resolves the
55
+ * config-file key, the provider environment variable and the one-shot
56
+ * `--api-key` override. Injected rather than reimplemented for the reason
57
+ * `resolveFastTier` records: a second key resolver is a second answer to
58
+ * "why does it say no key?".
59
+ */
60
+ export function resolveSubagentModel(config, hasKey) {
61
+ // `clampTeamConfig` guarantees the enum on every read and write path, so the
62
+ // default `'main'` here is belt-and-braces rather than a real branch.
63
+ const mode = config.team.model ?? 'main';
64
+ if (mode === 'fast') {
65
+ const tier = resolveFastTier(config, hasKey);
66
+ if (tier.ok) {
67
+ return {
68
+ mode,
69
+ effective: 'fast',
70
+ ref: tier.ref,
71
+ thinkingLevel: config.fast.thinkingLevel,
72
+ keyRole: 'fast',
73
+ profile: null,
74
+ };
75
+ }
76
+ return fallbackToMain(config, mode, 'fast_unavailable');
77
+ }
78
+ if (mode === 'profile') {
79
+ // A missing library, a missing id and a dangling id are one outcome
80
+ // (D-4): degrade to main and say so. Reference EXISTENCE is deliberately
81
+ // not validated in `clampTeamConfig` — resolution is where it belongs.
82
+ const id = config.team.modelProfileId;
83
+ const entry = id
84
+ ? config.modelProfiles?.entries.find((candidate) => candidate.id === id)
85
+ : undefined;
86
+ if (!entry)
87
+ return fallbackToMain(config, mode, 'profile_missing');
88
+ // The dedicated entry key wins; without one the shared layer must have a
89
+ // key for the entry's provider, or the child could not authenticate at all.
90
+ if (!entry.apiKey && !hasKey(entry.provider, 'main')) {
91
+ return fallbackToMain(config, mode, 'profile_no_key');
92
+ }
93
+ return {
94
+ mode,
95
+ effective: 'profile',
96
+ ref: {
97
+ providerId: entry.provider,
98
+ modelId: entry.model,
99
+ ...(entry.baseUrl ? { baseUrl: entry.baseUrl } : {}),
100
+ },
101
+ thinkingLevel: config.thinkingLevel,
102
+ keyRole: 'main',
103
+ profile: entry,
104
+ };
105
+ }
106
+ return {
107
+ mode: 'main',
108
+ effective: 'main',
109
+ ref: mainRef(config),
110
+ thinkingLevel: config.thinkingLevel,
111
+ keyRole: 'main',
112
+ profile: null,
113
+ };
114
+ }
115
+ /**
116
+ * The key a CHILD resolves for a provider, profile-aware (§3.3 / I-4).
117
+ *
118
+ * A profile's dedicated key is visible ONLY here: it never reaches env,
119
+ * `config.apiKeys` or a log line. While the resolution's profile is bound and
120
+ * the provider matches it, the entry key takes priority over the shared layer;
121
+ * every other provider (and every non-profile mode) resolves through the
122
+ * lead's own resolver at `resolution.keyRole` — which is what keeps a `fast`
123
+ * child entitled to the fast-role key and a `main` child to the main one.
124
+ *
125
+ * @param config The live config (carried for parity with the resolver pair).
126
+ * @param resolution A fresh `resolveSubagentModel` output.
127
+ * @param providerId The provider the child needs a key for.
128
+ * @param shared The lead-layer resolver (`controller.resolveKey`).
129
+ */
130
+ export function resolveSubagentKey(config, resolution, providerId, shared) {
131
+ void config; // parity parameter: the pair reads one config, the key does not need it
132
+ if (resolution.profile && providerId === resolution.ref.providerId) {
133
+ return resolution.profile.apiKey ?? shared(providerId, 'main');
134
+ }
135
+ return shared(providerId, resolution.keyRole);
136
+ }
@@ -21,6 +21,7 @@
21
21
  * because of one HTTP 429.
22
22
  */
23
23
  import type { ModelCost } from '@aragon-agent/core';
24
+ import type { SubagentModelResolution } from './model.js';
24
25
  import type { DispatchOutcome, SubagentRun } from './types.js';
25
26
  export interface ReportOptions {
26
27
  /** The lead's model cost table, so the header can state real spend (R-5).
@@ -37,6 +38,16 @@ export interface ReportOptions {
37
38
  * rule the fast tier already follows everywhere else).
38
39
  */
39
40
  fastCost?: ModelCost;
41
+ /**
42
+ * The dispatch-time subagent model resolution (subagent-model §3.4).
43
+ *
44
+ * ABSENT under the default `team.model: 'main'` mode is what keeps the
45
+ * parity report byte-identical (I-2): the disclosure line exists only when
46
+ * children did NOT run the main model, and a resolution that degraded
47
+ * (fast tier off, profile deleted, profile key missing) says so instead of
48
+ * silently renaming the model the children fell back to (D-4).
49
+ */
50
+ subagentModel?: SubagentModelResolution;
40
51
  }
41
52
  /**
42
53
  * Cut `text` to at most `maxBytes` WITHOUT splitting a UTF-8 sequence.
@@ -106,8 +106,31 @@ function buildHeader(outcome, options) {
106
106
  `(${counts}) in ${formatDuration(durationMs)}.`
107
107
  : `Team dispatch: ${outcome.runs.length} of ${outcome.runs.length} subagents finished ` +
108
108
  `(${counts}) in ${formatDuration(durationMs)}.`);
109
+ // THE PRICE OF THE MODEL CHILDREN ACTUALLY RAN (subagent-model §3.4). The
110
+ // default main flow always supplies a table, so the ternary's last branch
111
+ // is the legacy byte-identical shape; an UNPRICED fast or profile child
112
+ // model says `pricing unknown` - unknown pricing is not zero pricing, and
113
+ // `$0.00` on a dispatch that is spending money is the C-11 / RV-4 lie.
114
+ const spend = options.cost
115
+ ? formatCost(computeCost(outcome.usage, options.cost))
116
+ : options.subagentModel && options.subagentModel.effective !== 'main'
117
+ ? 'pricing unknown'
118
+ : formatCost(computeCost(outcome.usage, options.cost));
109
119
  lines.push(`Tokens: in ${formatTokens(outcome.usage.inputTokens)}, out ` +
110
- `${formatTokens(outcome.usage.outputTokens)}. Cost: ${formatCost(computeCost(outcome.usage, options.cost))}.`);
120
+ `${formatTokens(outcome.usage.outputTokens)}. Cost: ${spend}.`);
121
+ // THE MODEL DISCLOSURE LINE (D-4), immediately after the spend it explains.
122
+ // Rendered only when children did not run the main model, so the default
123
+ // report stays byte-identical (I-2) and a degraded request says WHICH mode
124
+ // was asked for and WHY it fell back rather than quietly renaming it.
125
+ const subagent = options.subagentModel;
126
+ if (subagent && subagent.effective !== 'main') {
127
+ lines.push(`Children ran on ${subagent.ref.providerId}:${subagent.ref.modelId} ` +
128
+ `(subagent model: ${subagent.effective}).`);
129
+ }
130
+ else if (subagent && subagent.fallbackReason) {
131
+ lines.push(`Subagent model ${subagent.mode} unavailable (${subagent.fallbackReason}); ` +
132
+ `children ran on the main model.`);
133
+ }
111
134
  for (const conflict of findFileConflicts(outcome.runs)) {
112
135
  const who = conflict.labels.map((l) => `"${l}"`).join(' and ');
113
136
  lines.push(`WARNING: agents ${who} both wrote ${conflict.path} - review before trusting either.`);