@aragon-agent/cli 0.6.22 → 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 (48) hide show
  1. package/README.md +20 -2
  2. package/dist/agent/controller.d.ts +11 -0
  3. package/dist/agent/controller.js +42 -4
  4. package/dist/agent/model-profile-settings.js +29 -2
  5. package/dist/agent/reducer.js +8 -6
  6. package/dist/commands/builtins.js +87 -3
  7. package/dist/compaction/child.d.ts +15 -0
  8. package/dist/compaction/child.js +14 -3
  9. package/dist/compaction/compactor.js +1 -0
  10. package/dist/compaction/context-command.js +10 -1
  11. package/dist/compaction/meter.d.ts +9 -0
  12. package/dist/compaction/meter.js +16 -1
  13. package/dist/compaction/pressure.d.ts +7 -0
  14. package/dist/compaction/pressure.js +5 -0
  15. package/dist/compaction/types.d.ts +11 -0
  16. package/dist/compaction/wiring.js +1 -0
  17. package/dist/config/cli-commands.d.ts +1 -1
  18. package/dist/config/cli-commands.js +12 -0
  19. package/dist/config/load.js +66 -9
  20. package/dist/config/model-profile-resolution.d.ts +8 -0
  21. package/dist/config/model-profile-resolution.js +13 -6
  22. package/dist/config/model-profile-store.d.ts +4 -2
  23. package/dist/config/model-profiles.d.ts +13 -0
  24. package/dist/config/model-profiles.js +18 -2
  25. package/dist/config/schema.d.ts +86 -0
  26. package/dist/config/schema.js +74 -0
  27. package/dist/team/model.d.ts +88 -0
  28. package/dist/team/model.js +136 -0
  29. package/dist/team/overseer.d.ts +5 -3
  30. package/dist/team/overseer.js +15 -4
  31. package/dist/team/report.d.ts +11 -0
  32. package/dist/team/report.js +24 -1
  33. package/dist/team/runtime.js +20 -3
  34. package/dist/team/subagent.d.ts +10 -0
  35. package/dist/team/subagent.js +57 -27
  36. package/dist/team/task-tool.d.ts +25 -6
  37. package/dist/team/task-tool.js +15 -9
  38. package/dist/team/types.d.ts +4 -3
  39. package/dist/ui/App.js +5 -0
  40. package/dist/ui/overlays/ModelProfileEditor.js +18 -4
  41. package/dist/ui/overlays/ModelProfilePicker.d.ts +14 -2
  42. package/dist/ui/overlays/ModelProfilePicker.js +51 -10
  43. package/dist/ui/overlays/QuestionOverlay.js +47 -6
  44. package/dist/ui/overlays/SettingsScreen.d.ts +9 -0
  45. package/dist/ui/overlays/SettingsScreen.js +119 -17
  46. package/dist/ui/overlays/model-profile-state.d.ts +53 -4
  47. package/dist/ui/overlays/model-profile-state.js +111 -14
  48. package/package.json +2 -2
@@ -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
+ }
@@ -49,8 +49,10 @@ import type { OverseerAction, OverseerDecision, SubagentRun, SubagentSpec } from
49
49
  * Bumped whenever either supervisor protocol prompt changes (D-9), so a
50
50
  * behaviour report can be tied to a prompt revision with one grep.
51
51
  * `TEAM_BLOCK_VERSION` covers only the LEAD's `<team>` block, not these.
52
+ *
53
+ * v3 adds the turn-cap trigger to the single-inspection prompt (F-8 A'2).
52
54
  */
53
- export declare const OVERSEER_PROMPT_VERSION = "v2-2026-10";
55
+ export declare const OVERSEER_PROMPT_VERSION = "v3-2026-10";
54
56
  /** One remembered decision, so the second look knows the first happened. */
55
57
  export interface OverseerMemoryEntry {
56
58
  /** Seconds into the dispatch. */
@@ -63,7 +65,7 @@ export interface ChildDigestInput {
63
65
  run: SubagentRun;
64
66
  /** False only in the post-mortem inspection, after the child settled. */
65
67
  childAlive: boolean;
66
- trigger: 'silence' | 'clock' | 'postmortem';
68
+ trigger: 'silence' | 'clock' | 'turncap' | 'postmortem';
67
69
  /** The child's LIVE history (`SubagentAgentLike.state.messages`). */
68
70
  messages: readonly Message[];
69
71
  memory: readonly OverseerMemoryEntry[];
@@ -203,7 +205,7 @@ export declare function createFastOverseerProvider(deps: FastOverseerDeps): Over
203
205
  export interface OverseerInspectRequest {
204
206
  run: SubagentRun;
205
207
  messages: readonly Message[];
206
- trigger: 'silence' | 'clock' | 'postmortem';
208
+ trigger: 'silence' | 'clock' | 'turncap' | 'postmortem';
207
209
  childAlive: boolean;
208
210
  }
209
211
  export interface TeamOverseerOptions {
@@ -50,8 +50,10 @@ import { TEAM_LIMITS } from './limits.js';
50
50
  * Bumped whenever either supervisor protocol prompt changes (D-9), so a
51
51
  * behaviour report can be tied to a prompt revision with one grep.
52
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).
53
55
  */
54
- export const OVERSEER_PROMPT_VERSION = 'v2-2026-10';
56
+ export const OVERSEER_PROMPT_VERSION = 'v3-2026-10';
55
57
  /** How many trailing history messages the digest carries. */
56
58
  const DIGEST_TAIL_MESSAGES = 8;
57
59
  /** Per-message text clamp inside the digest. */
@@ -78,6 +80,13 @@ function messageText(message) {
78
80
  function clampChars(text, max) {
79
81
  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 3))}...`;
80
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
+ };
81
90
  /**
82
91
  * Build the bounded text one inspection reads.
83
92
  *
@@ -91,7 +100,7 @@ export function buildChildDigest(input) {
91
100
  const { run, childAlive, trigger, messages, memory, elapsedSec } = input;
92
101
  const lines = [];
93
102
  lines.push(`child: ${run.label} "${run.description}"`);
94
- 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]}`);
95
104
  lines.push(`elapsed: ${Math.round(elapsedSec)}s, alive: ${childAlive ? 'yes' : 'no'}`);
96
105
  lines.push(`phase: ${run.phase}, turns: ${run.turns}, toolCalls: ${run.toolCalls}` +
97
106
  (run.lastTool ? `, lastTool: ${run.lastTool}` : ''));
@@ -270,8 +279,10 @@ export function parseOverseerBatchText(text) {
270
279
  }
271
280
  const OVERSEER_SYSTEM_PROMPT = [
272
281
  'You are the supervisor of a team of subagents. You are shown ONE subagent',
273
- 'that crossed a time trigger (event silence or a wall-clock check) and must',
274
- '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:',
275
286
  '{"action": "wait" | "nudge" | "replace" | "abandon", "reason": "<= 200 chars",',
276
287
  ' "guidance": "<= 1200 chars, nudge only", "nextCheckMs": <ms, wait only>}.',
277
288
  'wait: the child looks healthy or is legitimately blocked (API backoff,',
@@ -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.`);
@@ -488,6 +488,18 @@ export class TeamRuntime {
488
488
  // ONE KICK PER REAL AGENT EVENT, unthrottled (I-OV3): the silence
489
489
  // window resets on evidence, not on a coalesced UI frame.
490
490
  onActivity: () => this.supervision?.kick(spec.label),
491
+ // A'2: under supervision the turn cap is ONE inspection, fired from the
492
+ // child's own `turn_end` at the crossing turn. Async-and-forgotten
493
+ // exactly like the silence timer's `onFire`: nothing in the child's
494
+ // event path may block or throw. ABSENT in the legacy regime, which
495
+ // keeps the hard stop - the hook's presence IS the gate.
496
+ ...(this.overseer !== null
497
+ ? {
498
+ onTurnCapReached: (label) => {
499
+ void this.inspectAndApply(dispatchId, label, 'turncap');
500
+ },
501
+ }
502
+ : {}),
491
503
  });
492
504
  // ASSIGNMENT, not a declaration: a `const` here would shadow the `let` above
493
505
  // the bus, `canSend` would see the empty array forever, and every peer would
@@ -520,10 +532,15 @@ export class TeamRuntime {
520
532
  // its slowest child and only an Esc ends it early. The lead's idle
521
533
  // watchdog is paused for the whole dispatch either way, exactly as it is
522
534
  // for the lead's own long turns.
535
+ //
536
+ // D-4, NOW FOR THE WHOLE DISPATCH TOO: with the supervisor gate open the
537
+ // ceiling NEVER arms a hard abort - the cadence ladder is what looks at a
538
+ // long dispatch, exactly as it already looks at a long child. The legacy
539
+ // hard `abortAll()` exists ONLY under `team.overseer: false`, the same
540
+ // regime line `runCtx.subagentTimeoutMs` below draws.
541
+ const dispatchCeilingMs = team.overseer ? 0 : team.dispatchTimeoutMs;
523
542
  this.dispatchTimer =
524
- team.dispatchTimeoutMs > 0
525
- ? setTimeout(() => this.abortAll(), team.dispatchTimeoutMs)
526
- : null;
543
+ dispatchCeilingMs > 0 ? setTimeout(() => this.abortAll(), dispatchCeilingMs) : null;
527
544
  const runCtx = {
528
545
  dispatchId,
529
546
  handles,
@@ -135,6 +135,16 @@ export interface SubagentHooks {
135
135
  * so the phase read at fire time is current.
136
136
  */
137
137
  onActivity?: () => void;
138
+ /**
139
+ * THE TURN CAP AS AN INSPECTION TRIGGER (F-8 A'2). Called ONCE, at the turn
140
+ * that crossed `maxTurnsPerSubagent`, ONLY when the dispatch supervisor is
141
+ * live - the runtime wires this hook under the same gate as
142
+ * `overseerActive`, so its presence IS the supervised/legacy branch. The
143
+ * child is NOT stopped: the supervisor decides wait / nudge / replace /
144
+ * abandon through the same `inspectAndApply` path every other trigger
145
+ * uses, and a child it lets run carries no `truncated` flag.
146
+ */
147
+ onTurnCapReached?: (label: string) => void;
138
148
  }
139
149
  export interface SubagentHandle {
140
150
  spec: SubagentSpec;
@@ -18,6 +18,7 @@ import { createBuiltinTools } from '../tools/index.js';
18
18
  import { pickActivityArgs, sanitizeActivity } from './activity.js';
19
19
  import { TEAM_LIMITS, TEAM_SUBAGENT_TOOL_NAMES } from './limits.js';
20
20
  import { isRetryableStreamError } from './retry.js';
21
+ import { resolveSubagentKey, resolveSubagentModel } from './model.js';
21
22
  import { makeTeamSend, makeTeamWait, withMailboxTail } from './comm-tools.js';
22
23
  import { buildSubagentBlock } from './prompt.js';
23
24
  /** The default factory: a real `Agent` from `@aragon-agent/core`. */
@@ -162,25 +163,34 @@ export function createSubagent(spec, deps, hooks) {
162
163
  let agentRef = null;
163
164
  const getAgent = () => agentRef;
164
165
  const tools = buildSubagentTools(spec, deps, getAgent);
165
- // THE CHILD'S MODEL IS THE LEAD'S MODEL, READ FROM THE DISPATCH-TIME CONFIG
166
- // (main-agent parity). Same provider, same model id, same base URL, same
167
- // thinking level: a delegation is never a downgrade, and the only model a
168
- // child may surprise its lead with is one a settings-screen edit made live
169
- // between two dispatches.
166
+ // WHICH MODEL A CHILD RUNS IS A USER SETTING, RESOLVED FROM THE
167
+ // DISPATCH-TIME CONFIG through the one authority (subagent-model §3.4).
168
+ // `team.model: 'main'` - the default - is the historical main-agent parity
169
+ // exactly: same provider, model, base URL and thinking level as the lead.
170
+ // `'fast'` / `'profile'` are the user's explicit, visible, reversible cost
171
+ // switches, and EVERY failure mode (tier off, profile deleted, profile key
172
+ // missing) degrades back to the main connection with a disclosed reason
173
+ // rather than failing the dispatch (D-4). The MODEL itself still cannot
174
+ // choose: `normalizeSubagentSpecs` drops a stale `model` key (D-1).
170
175
  //
171
176
  // `maxTokens` inherits `deps.config.maxTokens` below - the same ambition the
172
- // lead sends, clamped by the same adapter, so an inherited large value
173
- // degrades to the right number rather than to an HTTP 400.
174
- const resolved = {
175
- ref: {
176
- providerId: deps.config.provider,
177
- modelId: deps.config.model,
178
- ...(deps.config.baseUrl ? { baseUrl: deps.config.baseUrl } : {}),
179
- },
180
- thinkingLevel: deps.config.thinkingLevel,
181
- role: 'main',
182
- };
183
- const model = resolved.ref;
177
+ // lead sends, clamped by Core's `resolveOutputTokens` against the CHILD
178
+ // model's own ceiling, so an inherited large value degrades to the right
179
+ // number rather than to an HTTP 400.
180
+ const resolution = resolveSubagentModel(deps.config, (id, role) => Boolean(deps.getApiKey(id, role)));
181
+ const model = resolution.ref;
182
+ // THE CHILD'S OWN KEY CLOSURE, PROFILE-AWARE (§3.3 / I-4). A bound profile's
183
+ // dedicated key is visible ONLY inside this closure - never in env, config
184
+ // `apiKeys` or a log line - while every other provider resolves through the
185
+ // lead's resolver at `resolution.keyRole`, which is what keeps a fast child
186
+ // entitled to the fast-role key and a main child to the main one.
187
+ const childGetApiKey = (id) => resolveSubagentKey(deps.config, resolution, id, (providerId, role) => deps.getApiKey(providerId, role));
188
+ // The boolean twin, for the child's compaction summarizer (V-4): the child's
189
+ // own resolution first, the lead layer by role second. Forwarding only
190
+ // `deps.getApiKey` would hide a profile-dedicated key from `resolveSummarizer`
191
+ // and silently disable the child's compaction - the exact gap the req-level
192
+ // resolvers on `ChildContextManagerRequest` exist to close.
193
+ const childHasKey = (id, role) => Boolean(childGetApiKey(id) ?? deps.getApiKey(id, role));
184
194
  // HOISTED TO A LOCAL, AND THAT IS WHAT MAKES W3 POSSIBLE AT ALL. This was built
185
195
  // INLINE inside the `agentFactory({...})` argument, so there was nothing for the
186
196
  // child manager's `getSystemPrompt` accessor to close over - and the accessor
@@ -216,7 +226,12 @@ export function createSubagent(spec, deps, hooks) {
216
226
  const contextManager = deps.contextManagerFor?.({
217
227
  label: spec.label,
218
228
  model,
219
- role: resolved.role ?? 'main',
229
+ // keyRole (V-4): a fast child's summarizer resolves its main choice with
230
+ // role='fast', so a fast-only key is visible to it; profile keys reach it
231
+ // through the req-level resolvers below instead.
232
+ role: resolution.keyRole,
233
+ getApiKey: childGetApiKey,
234
+ hasKey: childHasKey,
220
235
  getMessages: () => agentRef?.state.messages ?? [],
221
236
  getSystemPrompt: () => systemPrompt,
222
237
  onCompacted: () => {
@@ -227,12 +242,13 @@ export function createSubagent(spec, deps, hooks) {
227
242
  systemPrompt,
228
243
  model,
229
244
  tools,
230
- thinkingLevel: resolved.thinkingLevel,
245
+ thinkingLevel: resolution.thinkingLevel,
231
246
  // The LEAD's registry instance, REUSED (P2-2). It holds adapter state, and
232
247
  // constructing five of them per dispatch is pure waste.
233
248
  providerRegistry: deps.providerRegistry,
234
- // The lead's closure, so a settings-screen key edit reaches children too.
235
- getApiKey: (id) => deps.getApiKey(id, resolved.role ?? 'main'),
249
+ // The child's own profile-aware closure, so a settings-screen key edit
250
+ // reaches children too and a bound profile's key reaches ONLY them.
251
+ getApiKey: childGetApiKey,
236
252
  ...(deps.config.maxTokens !== undefined ? { maxTokens: deps.config.maxTokens } : {}),
237
253
  timeouts: {
238
254
  toolTimeout: deps.config.toolTimeoutMs,
@@ -359,12 +375,26 @@ export function createSubagent(spec, deps, hooks) {
359
375
  // not runaway - it is working.
360
376
  const turnCap = deps.config.team.maxTurnsPerSubagent;
361
377
  if (turnCap > 0 && run.turns >= turnCap) {
362
- run.truncated = true;
363
- // THE FLAG FIRST, exactly like `handle.abort()`: the child's
364
- // reviewer reads `abortRequested` as its guard-1 fact, and a
365
- // turn-cap stop must be visible to it in the same tick.
366
- abortRequested = true;
367
- agent.abort();
378
+ if (hooks.onTurnCapReached !== undefined) {
379
+ // A'2, D-4 for the turn cap: SUPERVISED means the ceiling is
380
+ // an inspection trigger, not a death sentence. ONE look at the
381
+ // crossing turn (`===`), never a per-turn storm; the child
382
+ // keeps working unless the supervisor decides replace or
383
+ // abandon, and a child that keeps running carries NO
384
+ // `truncated` flag - the report must not call it partial.
385
+ if (run.turns === turnCap)
386
+ hooks.onTurnCapReached(spec.label);
387
+ }
388
+ else {
389
+ // LEGACY regime (no supervisor for this dispatch): the hard
390
+ // stop, byte for byte what it always was.
391
+ run.truncated = true;
392
+ // THE FLAG FIRST, exactly like `handle.abort()`: the child's
393
+ // reviewer reads `abortRequested` as its guard-1 fact, and a
394
+ // turn-cap stop must be visible to it in the same tick.
395
+ abortRequested = true;
396
+ agent.abort();
397
+ }
368
398
  }
369
399
  break;
370
400
  }
@@ -20,14 +20,17 @@
20
20
  * in the schema would behave differently depending on whether `ajv`, an OPTIONAL
21
21
  * dependency of core, happened to install.
22
22
  *
23
- * EVERY CHILD RUNS THE LEAD'S OWN MODEL (main-agent parity): same provider,
24
- * model, thinking level and token ambition, resolved from the LIVE config at
25
- * dispatch. There is no per-child `model` property and no cheap tier to pick -
26
- * a delegation is never a downgrade, and the fast tier reaches children the
27
- * same way it reaches the lead (compaction summaries, per-child reviews).
23
+ * WHICH MODEL A CHILD RUNS IS THE USER'S SETTING, NOT THE MODEL'S (D-1):
24
+ * `team.model` defaults to the lead's own connection (main-agent parity) and
25
+ * can name the fast tier or a profile; there is still no per-child `model`
26
+ * property, so a delegation is never a self-downgrade. Resolution lives in
27
+ * `team/model.ts` and is re-read from the LIVE config at every dispatch; the
28
+ * fast tier otherwise reaches children the way it reaches the lead
29
+ * (compaction summaries, per-child reviews).
28
30
  */
29
31
  import { type AgentTool } from '@aragon-agent/core';
30
32
  import type { ModelCost } from '@aragon-agent/core';
33
+ import type { SubagentModelResolution } from './model.js';
31
34
  import type { TeamRuntime } from './runtime.js';
32
35
  export interface TaskToolDeps {
33
36
  runtime: TeamRuntime;
@@ -37,8 +40,24 @@ export interface TaskToolDeps {
37
40
  /** Whether a usable key exists for the active provider right now (P2-3). */
38
41
  hasApiKey: () => boolean;
39
42
  activeProvider: () => string;
40
- /** The lead's cost table, so the report header states real spend (R-5). */
43
+ /**
44
+ * The cost table of the model children ACTUALLY run (subagent-model §3.4).
45
+ *
46
+ * The controller resolves `team.model` live and prices `modelCost` at the
47
+ * EFFECTIVE model's table: with the default `main` mode this is exactly the
48
+ * lead's own table, so the report's spend line is byte-identical to the
49
+ * parity behavior; with `fast` / `profile` it is that model's table, and an
50
+ * UNPRICED one yields `undefined` so the header says `pricing unknown`
51
+ * rather than fabricating `$0.00` (C-11 / RV-4).
52
+ */
41
53
  modelCost: () => ModelCost | undefined;
54
+ /**
55
+ * The dispatch-time subagent model resolution, for the report's disclosure
56
+ * line (D-4): rendered ONLY when children did not run the main model, and
57
+ * carrying the fallback reason when they could not. OPTIONAL so existing
58
+ * construction sites compile; absent simply omits the line.
59
+ */
60
+ subagentModel?: () => SubagentModelResolution;
42
61
  /**
43
62
  * The FAST tier's cost table, so the report's supervisor spend line states
44
63
  * real spend (subagent-overseer-v2 D-7 / R-P1-2). OPTIONAL and returning
@@ -20,11 +20,13 @@
20
20
  * in the schema would behave differently depending on whether `ajv`, an OPTIONAL
21
21
  * dependency of core, happened to install.
22
22
  *
23
- * EVERY CHILD RUNS THE LEAD'S OWN MODEL (main-agent parity): same provider,
24
- * model, thinking level and token ambition, resolved from the LIVE config at
25
- * dispatch. There is no per-child `model` property and no cheap tier to pick -
26
- * a delegation is never a downgrade, and the fast tier reaches children the
27
- * same way it reaches the lead (compaction summaries, per-child reviews).
23
+ * WHICH MODEL A CHILD RUNS IS THE USER'S SETTING, NOT THE MODEL'S (D-1):
24
+ * `team.model` defaults to the lead's own connection (main-agent parity) and
25
+ * can name the fast tier or a profile; there is still no per-child `model`
26
+ * property, so a delegation is never a self-downgrade. Resolution lives in
27
+ * `team/model.ts` and is re-read from the LIVE config at every dispatch; the
28
+ * fast tier otherwise reaches children the way it reaches the lead
29
+ * (compaction summaries, per-child reviews).
28
30
  */
29
31
  import { errorResult, textResult } from '@aragon-agent/core';
30
32
  import { TEAM_LIMITS } from './limits.js';
@@ -39,8 +41,9 @@ const TEAM_OFF_REFUSAL = 'Team mode is off for this session. Do this work yourse
39
41
  */
40
42
  const TASK_DESCRIPTION = 'Run several independent subagents in parallel and get one combined report. Each ' +
41
43
  'subagent is a fresh agent with the same tools and working directory but NO memory of ' +
42
- 'this conversation - its prompt must be self-contained. Each runs on the same model ' +
43
- 'you are running on. Use this when a task splits ' +
44
+ 'this conversation - its prompt must be self-contained. Each runs on the model the ' +
45
+ 'user configured for subagents (the main model by default). Use this when a task ' +
46
+ 'splits ' +
44
47
  'into 2 or more parts that do not depend on each other\'s output, especially reading or ' +
45
48
  'searching several areas at once. Do NOT use it when one part needs another part\'s ' +
46
49
  'result (do those yourself, in order), when two parts would edit the same file, or for ' +
@@ -120,13 +123,16 @@ export function createTaskTool(deps) {
120
123
  // A cancellation or a partial result rendered as a tool FAILURE invites
121
124
  // the model to retry the whole fan-out, which is the most expensive
122
125
  // possible reaction (the same reasoning as `ask_user`'s cancelled
123
- // shape, and D-14 one level up). Every child ran the lead's own model,
124
- // so the lead's cost table is the one honest price for the total.
126
+ // shape, and D-14 one level up). `modelCost` is priced at the model
127
+ // children ACTUALLY ran - the lead's own table under the default
128
+ // `main` mode - so the total stays one honest price.
125
129
  const cost = deps.modelCost();
126
130
  const fastCost = deps.fastModelCost?.();
131
+ const subagentModel = deps.subagentModel?.();
127
132
  return textResult(buildDispatchReport(outcome, {
128
133
  ...(cost ? { cost } : {}),
129
134
  ...(fastCost ? { fastCost } : {}),
135
+ ...(subagentModel ? { subagentModel } : {}),
130
136
  }));
131
137
  },
132
138
  };
@@ -201,13 +201,14 @@ export interface OverseerIntervention {
201
201
  action: OverseerAction;
202
202
  reason: string;
203
203
  /**
204
- * What the trigger was: event stall, wall-clock check, post-mortem, or
205
- * the supervisor going QUIET (subagent-overseer-v2 D-10) - the look
204
+ * What the trigger was: event stall, wall-clock check, the child's turn
205
+ * cap (F-8 A'2 - an inspection the supervisor then ruled on), post-mortem,
206
+ * or the supervisor going QUIET (subagent-overseer-v2 D-10) - the look
206
207
  * budget ran out and supervision stopped waking this child. A VALUE OF
207
208
  * THE UNION, never a reason prefix: a human-readable sentence is not a
208
209
  * machine contract (the `run.error` rule).
209
210
  */
210
- trigger: 'silence' | 'clock' | 'postmortem' | 'quiet';
211
+ trigger: 'silence' | 'clock' | 'turncap' | 'postmortem' | 'quiet';
211
212
  }
212
213
  /**
213
214
  * A terminal supervisor decision handed to the WORKER LOOP, which is the only
package/dist/ui/App.js CHANGED
@@ -2144,6 +2144,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2144
2144
  // `auto`, not an empty string: an empty field reads as "unset", which
2145
2145
  // is indistinguishable from AUTO and is not a state this can be in.
2146
2146
  maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : 'auto',
2147
+ // BOTH HALVES, seeded AND read back (§6.3): the sibling comment above
2148
+ // records what omitting either one costs. `auto` mirrors the row's
2149
+ // empty-field reading; a bound Main profile hides the row entirely
2150
+ // and the profile editor seeds from the entry instead.
2151
+ contextWindow: cfg.contextWindow === null ? 'auto' : String(cfg.contextWindow),
2147
2152
  apiKey: '',
2148
2153
  logLevel: cfg.log.level,
2149
2154
  // The four fast rows, seeded through the pure mapper so the screen and
@@ -9,8 +9,13 @@ import { stripEnterFrames } from '../enter-frames.js';
9
9
  import { pickGlyphs } from '../glyphs.js';
10
10
  import { profileEndpointSummary, useProfileScroll } from './ModelProfilePicker.js';
11
11
  import { finishProfileEditor } from './model-profile-state.js';
12
- const FIELDS = ['name', 'provider', 'model', 'baseUrl', 'credentialMode', 'apiKey'];
13
- const LABELS = ['Name', 'Provider', 'Model', 'Base URL', 'Credential mode', 'API key'];
12
+ // `Context window` sits right after Model: the window is an attribute of the
13
+ // model+endpoint the two rows above name. 14 characters, inside the 18-column
14
+ // label budget `LABELS.map(...padEnd(18))` enforces below.
15
+ const FIELDS = ['name', 'provider', 'model', 'contextWindow', 'baseUrl', 'credentialMode',
16
+ 'apiKey'];
17
+ const LABELS = ['Name', 'Provider', 'Model', 'Context window', 'Base URL', 'Credential mode',
18
+ 'API key'];
14
19
  function useEditorInput(props, navigation) {
15
20
  const { editor, dispatch } = props;
16
21
  const { index, setIndex, retain, count } = navigation;
@@ -83,8 +88,11 @@ function buildEditorRows(props, navigation) {
83
88
  : editor.credentialMode === 'keep' ? 'Configured (unchanged)' : 'Shared / environment';
84
89
  if (field === 'credentialMode')
85
90
  return editor.credentialMode;
86
- return editor[field] || (field === 'name' ? '(defaults to model)' : field === 'baseUrl'
87
- ? '(provider default)' : '(required)');
91
+ // `(auto)` EXPLICITLY, or an empty window field would fall through to
92
+ // `(required)` - and AUTO is this field's natural state, never a gap.
93
+ return editor[field] || (field === 'name' ? '(defaults to model)'
94
+ : field === 'contextWindow' ? '(auto)' : field === 'baseUrl'
95
+ ? '(provider default)' : '(required)');
88
96
  });
89
97
  const lines = LABELS.map((label, at) => `${label.padEnd(18)}${values[at]}`);
90
98
  if (retain)
@@ -92,6 +100,12 @@ function buildEditorRows(props, navigation) {
92
100
  lines.push('Apply to settings draft', 'Cancel editing');
93
101
  const rows = lines.map((line, at) => _jsxs(Text, { wrap: "truncate", color: at === index ? props.theme.accent : props.theme.muted, children: [at === index ? `${glyphs.caret} ` : ' ', line] }, at));
94
102
  rows.push(_jsxs(Text, { wrap: "truncate", color: props.theme.muted, children: ["Target: ", editor.provider, " ", profileEndpointSummary(editor.baseUrl)] }, "target"));
103
+ // HONESTY FOR THE FAST-ONLY ENTRY (§6.5): a window saved on a profile that
104
+ // only ever serves the Fast role does not take effect today, and saying so
105
+ // beats letting the user believe it does.
106
+ if (props.users === 'Fast') {
107
+ rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: "Window applies when this profile is bound as Main." }, "window-scope"));
108
+ }
95
109
  return rows;
96
110
  }
97
111
  /** Replacement key input starts empty; retention across targets is explicit. */