@sublang/playbook 17.1.0 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/cli.md CHANGED
@@ -202,8 +202,8 @@ The former positional `<from>`, `resume`, `--player`, `--captain`,
202
202
  `--option`, `--cwd`, `--last`, run-only `--config`, and top-level `run:`
203
203
  config are removed from `playbook run`. Enable a registry under `playbooks`,
204
204
  declare provider agents once under top-level `players`, bind every local role
205
- under `playbooks.<id>.roles`, tune compatible model, effort, and fast mode in a `--with`
206
- overlay, invoke the effective `/command`, and run from the working directory
205
+ under `playbooks.<id>.roles`, tune compatible model, effort, fast mode,
206
+ subagent model, and subagent effort in a `--with` overlay, invoke the effective `/command`, and run from the working directory
207
207
  you want agents to use. Legacy `playbooks.<id>.players` blocks are rejected and
208
208
  are not auto-migrated because choosing equal or distinct new player IDs chooses
209
209
  conversation sharing or isolation; see [Migrating per-playbook
@@ -280,10 +280,12 @@ competing front end fails closed instead of forking the history.
280
280
  An ordinary reopen reads current config and any opening `--with` fragments,
281
281
  projects them to the stored catalog and player roster, and requires the stored
282
282
  role bindings plus every structural setting to remain exact. Compatible
283
- current `model`, `effort`, and optional `fastMode` settings apply to the next
284
- call. Boolean `false` explicitly resets model or effort to the provider
285
- default, but `fastMode: false` is a literal disabled request; omission selects
286
- the fast-mode provider default or inherits the player value at a role binding.
283
+ current `model`, `effort`, and optional `fastMode`, `subagentModel`, and
284
+ `subagentEffort` settings apply to the next call. Boolean `false` explicitly
285
+ resets model, effort, subagent model, or subagent effort to the provider
286
+ default, but `fastMode: false` is a literal
287
+ disabled request; omission selects the provider default or inherits the player
288
+ value at a role binding.
287
289
  Provider conversations are local hints. Missing hints start fresh from stored
288
290
  context; a definite pre-execution rejection permits one fresh attempt.
289
291
  Unsupported settings and ambiguous failures do not trigger that fallback
@@ -48,8 +48,8 @@ names, nesting, and ancestry never infer a binding.
48
48
 
49
49
  Each `captain` or `players.<player-id>` value is either an adapter shorthand
50
50
  (`claude`, `codex`) or a block carrying that agent's own `adapter`, `model`,
51
- `effort`, `fastMode`, `instruction`, and `permissions`. Settings are inline per
52
- stable agent
51
+ `effort`, `fastMode`, `subagentModel`, `subagentEffort`, `instruction`, and
52
+ `permissions`. Settings are inline per stable agent
53
53
  ([DR-021](https://github.com/sublang-ai/playbook/blob/main/specs/decisions/021-inline-agent-settings.md)).
54
54
  Dots in a player ID are literal characters, not YAML hierarchy. Other adapter
55
55
  IDs pass through to `tmux-play` with a warning because `playbook` cannot
@@ -156,8 +156,8 @@ roles:
156
156
  coder: dev.coder
157
157
  ```
158
158
 
159
- Use a block to override only that role invocation's model, effort, or fast
160
- mode:
159
+ Use a block to override only that role invocation's model, effort, fast mode,
160
+ subagent model, or subagent effort:
161
161
 
162
162
  ```yaml
163
163
  roles:
@@ -166,17 +166,38 @@ roles:
166
166
  model: claude-sonnet-5-5
167
167
  effort: false # explicitly reset to this provider's default
168
168
  fastMode: false # literal disabled request, not a default sentinel
169
+ reviewer:
170
+ player: dev.reviewer
171
+ subagentModel: inherit # inherit | <model> | false
172
+ subagentEffort: medium # <effort> | false
169
173
  ```
170
174
 
171
- Omitting any override inherits that player's top-level default. For `model`
172
- and `effort`, boolean `false` selects the provider default explicitly, so a
173
- resumed conversation cannot accidentally retain an earlier selection. For
174
- `fastMode`, `false` is a literal request to disable fast mode; omitting the
175
- top-level setting selects the provider default. A present fast-mode boolean is
176
- accepted only for adapters Cligent reports as supporting it. A role binding
177
- cannot override adapter, instruction, permissions, workspace, or tool posture;
178
- those define the stable player envelope, so an overriding model must be one
179
- that player's adapter serves.
175
+ Omitting any override inherits that player's top-level default. For `model`,
176
+ `effort`, `subagentModel`, and `subagentEffort`, boolean `false` selects the
177
+ provider default explicitly, so a resumed conversation cannot accidentally
178
+ retain an earlier selection. For `fastMode`, `false` is a literal request to
179
+ disable fast mode; omitting the top-level setting selects the provider default.
180
+ A present fast-mode boolean, subagent model, or subagent effort is accepted
181
+ only for adapters Cligent reports as supporting it. A role binding cannot override adapter, instruction,
182
+ permissions, workspace, or tool posture; those define the stable player
183
+ envelope, so an overriding model must be one that player's adapter serves.
184
+
185
+ `subagentModel` makes every subagent the agent starts run on one model —
186
+ `inherit` names the agent's own, a model name pins another, and `false` on a
187
+ `captain` or `players.<player-id>` block switches delegation off — and tells
188
+ the agent to hand well-defined, fine-grained tasks to its subagents while
189
+ keeping the deep thinking, reasoning, and design work itself, without lowering
190
+ the quality of what it delivers
191
+ ([DR-075](https://github.com/sublang-ai/playbook/blob/main/specs/decisions/075-subagent-model-is-tuning.md)).
192
+ `subagentEffort` pins the effort every subagent runs at, any effort the
193
+ adapter accepts other than `ultracode`; left unset, the agent chooses an effort
194
+ per task, so its own effort never silently governs its subagents.
195
+ An agent whose adapter serves a subagent model (Claude today) and whose block
196
+ leaves `subagentModel` unset resolves to `inherit`: it delegates by default.
197
+ The launcher resolves this on its own copy and never rewrites your config, and
198
+ the starter config names neither field
199
+ ([DR-076](https://github.com/sublang-ai/playbook/blob/main/specs/decisions/076-subagents-delegate-by-default.md)).
200
+
180
201
 
181
202
  ## Sharing, isolation, and concurrency
182
203
 
@@ -269,6 +290,7 @@ playbooks:
269
290
  coder:
270
291
  player: dev.coder
271
292
  effort: low
293
+ subagentEffort: high # its subagents still run at high effort
272
294
  ```
273
295
 
274
296
  The global file is never modified, and `--with` is not forwarded to
@@ -276,9 +298,10 @@ The global file is never modified, and `--with` is not forwarded to
276
298
  Overlays apply when creating a fresh session and as current-config input for a
277
299
  compatible ordinary reopen. A selected session keeps its stored catalog,
278
300
  player roster, role bindings, adapter, instruction, permissions, and working
279
- directory; only model, effort, and fast mode may change. The next call reapplies
280
- both complete model and effort selections and the optional effective fast-mode
281
- boolean. An uncertain retry accepts no tuning overlay and only restores and reports the recorded attempt. Later turns use the current compatible settings.
301
+ directory; only model, effort, fast mode, subagent model, and subagent effort
302
+ may change. The next call reapplies both complete model and effort selections,
303
+ the optional effective fast-mode boolean, and the optional effective subagent
304
+ model and subagent effort. An uncertain retry accepts no tuning overlay and only restores and reports the recorded attempt. Later turns use the current compatible settings.
282
305
 
283
306
  ## Session storage
284
307
 
@@ -319,8 +342,8 @@ public UUID. Presentation-only fields are inert headlessly.
319
342
  An ordinary reopen reads current config and opening overlays, but first
320
343
  projects them to the stored playbooks and referenced players. An unrelated new
321
344
  entry cannot enter or invalidate the session. Structural drift fails closed;
322
- compatible model, effort, or fast-mode changes apply on the next provider
323
- call. Legacy record, shell, runtime-snapshot, and trace schemas are rejected
345
+ compatible model, effort, fast-mode, subagent-model, or subagent-effort
346
+ changes apply on the next provider call. Legacy record, shell, runtime-snapshot, and trace schemas are rejected
324
347
  rather than having role or player identity guessed.
325
348
 
326
349
  ## External playbooks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "17.1.0",
3
+ "version": "17.3.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, DEV, BRANCH, and PR workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -243,7 +243,7 @@
243
243
  "provenance": true
244
244
  },
245
245
  "dependencies": {
246
- "@sublang/cligent": "^0.28.0",
246
+ "@sublang/cligent": "^0.30.0",
247
247
  "@sublang/spex": "^3.0.0",
248
248
  "p-queue": "^9.3.1",
249
249
  "xstate": "^5.19.4",
@@ -28,6 +28,7 @@ import {
28
28
  parseDocument as parseYamlDocument,
29
29
  stringify as stringifyYaml,
30
30
  } from "yaml";
31
+ import { isSubagentModelSupported } from "@sublang/cligent";
31
32
  import { loadTmuxPlayConfig } from "@sublang/cligent/tmux-play";
32
33
  import {
33
34
  defaultCaptainSessionsDir,
@@ -379,12 +380,14 @@ export async function normalizeSelectedLaunchPlanDataOnly(
379
380
  const configuredAgents = new Map();
380
381
  for (const playerId of expectedMembers.playerIds) {
381
382
  assertPlayerId(playerId, `players.${playerId}`);
382
- const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
383
- "id",
384
- ]);
383
+ const agent = resolveDelegationDefault(
384
+ resolveAgent(playersCfg[playerId], `players.${playerId}`, ["id"]),
385
+ );
385
386
  configuredAgents.set(playerId, agent);
386
387
  }
387
- let captain = resolveAgent(top.captain, "captain", ["from", "options"]);
388
+ let captain = resolveDelegationDefault(
389
+ resolveAgent(top.captain, "captain", ["from", "options"]),
390
+ );
388
391
 
389
392
  const playbooksCfg = requireObject(top.playbooks, "playbooks");
390
393
  const tuningChecks = [];
@@ -442,7 +445,9 @@ export async function normalizeSelectedLaunchPlanDataOnly(
442
445
  if (
443
446
  binding.model !== undefined ||
444
447
  binding.effort !== undefined ||
445
- binding.fastMode !== undefined
448
+ binding.fastMode !== undefined ||
449
+ binding.subagentModel !== undefined ||
450
+ binding.subagentEffort !== undefined
446
451
  ) {
447
452
  let checkId;
448
453
  do {
@@ -558,6 +563,8 @@ export async function normalizeSelectedLaunchPlanDataOnly(
558
563
  ? {}
559
564
  : { fastMode: agent.fastMode }
560
565
  : { fastMode: binding.fastMode }),
566
+ ...effectiveOptionalTuning(binding, agent, "subagentModel"),
567
+ ...effectiveOptionalTuning(binding, agent, "subagentEffort"),
561
568
  },
562
569
  ];
563
570
  }),
@@ -687,9 +694,9 @@ export async function normalizeLaunchPlan(
687
694
  const configuredAgents = new Map();
688
695
  for (const playerId of allPlayerIds) {
689
696
  assertPlayerId(playerId, `players.${playerId}`);
690
- const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
691
- "id",
692
- ]);
697
+ const agent = resolveDelegationDefault(
698
+ resolveAgent(playersCfg[playerId], `players.${playerId}`, ["id"]),
699
+ );
693
700
  if (
694
701
  typeof agent.adapter !== "string" ||
695
702
  agent.adapter.trim().length === 0
@@ -708,7 +715,9 @@ export async function normalizeLaunchPlan(
708
715
  throw new Error("playbooks keys must be canonical trimmed nonblank ids");
709
716
  }
710
717
 
711
- let captain = resolveAgent(top.captain, "captain", ["from", "options"]);
718
+ let captain = resolveDelegationDefault(
719
+ resolveAgent(top.captain, "captain", ["from", "options"]),
720
+ );
712
721
  if (
713
722
  typeof captain.adapter !== "string" ||
714
723
  captain.adapter.trim().length === 0
@@ -787,7 +796,9 @@ export async function normalizeLaunchPlan(
787
796
  if (
788
797
  binding.model !== undefined ||
789
798
  binding.effort !== undefined ||
790
- binding.fastMode !== undefined
799
+ binding.fastMode !== undefined ||
800
+ binding.subagentModel !== undefined ||
801
+ binding.subagentEffort !== undefined
791
802
  ) {
792
803
  let checkId;
793
804
  do {
@@ -999,6 +1010,8 @@ export async function normalizeLaunchPlan(
999
1010
  ? {}
1000
1011
  : { fastMode: agent.fastMode }
1001
1012
  : { fastMode: binding.fastMode }),
1013
+ ...effectiveOptionalTuning(binding, agent, "subagentModel"),
1014
+ ...effectiveOptionalTuning(binding, agent, "subagentEffort"),
1002
1015
  },
1003
1016
  ];
1004
1017
  }),
@@ -1484,7 +1497,8 @@ function migrateUserConfigIfRetired(userConfigPath, onNotice) {
1484
1497
  throw new Error(
1485
1498
  `cannot migrate the retired profiles config at ${userConfigPath}: ` +
1486
1499
  `${errorMessage(error)} — edit it by hand: each agent takes its own ` +
1487
- "adapter, model, effort, fast mode, and permissions",
1500
+ "adapter, model, effort, fast mode, subagent model, subagent effort, " +
1501
+ "and permissions",
1488
1502
  );
1489
1503
  }
1490
1504
  if (migrated === undefined) return;
@@ -1620,7 +1634,8 @@ function assertNoRetiredProfiles(top, configPath) {
1620
1634
  throw new Error(
1621
1635
  `top-level "profiles" was removed${where}: write each agent's settings ` +
1622
1636
  "inline under captain and each top-level players.<player-id> " +
1623
- "(adapter, model, effort, fast mode, permissions)",
1637
+ "(adapter, model, effort, fast mode, subagent model, subagent effort, " +
1638
+ "permissions)",
1624
1639
  );
1625
1640
  }
1626
1641
  const legacyPath = findLegacyPlayersPath(top);
@@ -1635,7 +1650,8 @@ function assertNoRetiredProfiles(top, configPath) {
1635
1650
  if (isObject(block) && block.profile !== undefined) {
1636
1651
  throw new Error(
1637
1652
  `${path}.profile was removed${where}: write the agent's settings ` +
1638
- "inline in that block (adapter, model, effort, fast mode, permissions)",
1653
+ "inline in that block (adapter, model, effort, fast mode, " +
1654
+ "subagent model, subagent effort, permissions)",
1639
1655
  );
1640
1656
  }
1641
1657
  }
@@ -2034,13 +2050,21 @@ function resolveRoleBinding(value, path) {
2034
2050
  }
2035
2051
  const block = requireObject(value, path);
2036
2052
  const unknown = Object.keys(block).filter(
2037
- (key) => !["player", "model", "effort", "fastMode"].includes(key),
2053
+ (key) =>
2054
+ ![
2055
+ "player",
2056
+ "model",
2057
+ "effort",
2058
+ "fastMode",
2059
+ "subagentModel",
2060
+ "subagentEffort",
2061
+ ].includes(key),
2038
2062
  );
2039
2063
  if (unknown.length > 0) {
2040
2064
  throw new Error(`${path} has unknown ${formatKeyList(unknown)}`);
2041
2065
  }
2042
2066
  assertPlayerId(block.player, `${path}.player`);
2043
- for (const field of ["model", "effort"]) {
2067
+ for (const field of ["model", "effort", "subagentModel", "subagentEffort"]) {
2044
2068
  if (
2045
2069
  block[field] !== undefined &&
2046
2070
  block[field] !== false &&
@@ -2062,9 +2086,46 @@ function resolveRoleBinding(value, path) {
2062
2086
  ...(block.model === undefined ? {} : { model: block.model }),
2063
2087
  ...(block.effort === undefined ? {} : { effort: block.effort }),
2064
2088
  ...(block.fastMode === undefined ? {} : { fastMode: block.fastMode }),
2089
+ ...(block.subagentModel === undefined
2090
+ ? {}
2091
+ : { subagentModel: block.subagentModel }),
2092
+ ...(block.subagentEffort === undefined
2093
+ ? {}
2094
+ : { subagentEffort: block.subagentEffort }),
2065
2095
  };
2066
2096
  }
2067
2097
 
2098
+ // A binding's subagent model or subagent effort overrides the player's;
2099
+ // `false` selects the provider default, which a complete call expresses by
2100
+ // omission (for the effort: the agent chooses per task).
2101
+ function effectiveOptionalTuning(binding, agent, field) {
2102
+ if (binding[field] === false) return {};
2103
+ if (binding[field] !== undefined) return { [field]: binding[field] };
2104
+ return agent[field] === undefined ? {} : { [field]: agent[field] };
2105
+ }
2106
+
2107
+ // DR-076: an agent whose adapter serves a subagent model delegates by
2108
+ // default. Resolved on the launcher's detached copy of a Captain or player
2109
+ // block — never the user's file — before cligent normalization, so cligent
2110
+ // validates a subagent effort against the resolved model, and both front
2111
+ // ends and the headless run share the result. A block's `false` opts out:
2112
+ // nothing is sent and no delegation directive is composed.
2113
+ function resolveDelegationDefault(agent) {
2114
+ if (!isObject(agent)) return agent;
2115
+ if (agent.subagentModel === false) {
2116
+ const { subagentModel: _off, ...rest } = agent;
2117
+ return rest;
2118
+ }
2119
+ if (
2120
+ agent.subagentModel === undefined &&
2121
+ typeof agent.adapter === "string" &&
2122
+ isSubagentModelSupported(agent.adapter)
2123
+ ) {
2124
+ return { ...agent, subagentModel: "inherit" };
2125
+ }
2126
+ return agent;
2127
+ }
2128
+
2068
2129
  function applyTuningOverrides(agent, binding) {
2069
2130
  const effective = { ...agent };
2070
2131
  if (binding.model === false) delete effective.model;
@@ -2075,6 +2136,10 @@ function applyTuningOverrides(agent, binding) {
2075
2136
  else effective.effort = binding.effort;
2076
2137
  }
2077
2138
  if (binding.fastMode !== undefined) effective.fastMode = binding.fastMode;
2139
+ for (const field of ["subagentModel", "subagentEffort"]) {
2140
+ if (binding[field] === false) delete effective[field];
2141
+ else if (binding[field] !== undefined) effective[field] = binding[field];
2142
+ }
2078
2143
  return effective;
2079
2144
  }
2080
2145
 
@@ -2089,6 +2154,22 @@ function sessionAgentFromHostAgent(agent, path) {
2089
2154
  ...(agent.fastMode === undefined
2090
2155
  ? {}
2091
2156
  : { fastMode: fastModeSelection(agent.fastMode, `${path}.fastMode`) }),
2157
+ ...(agent.subagentModel === undefined
2158
+ ? {}
2159
+ : {
2160
+ subagentModel: nonblankSelection(
2161
+ agent.subagentModel,
2162
+ `${path}.subagentModel`,
2163
+ ),
2164
+ }),
2165
+ ...(agent.subagentEffort === undefined
2166
+ ? {}
2167
+ : {
2168
+ subagentEffort: nonblankSelection(
2169
+ agent.subagentEffort,
2170
+ `${path}.subagentEffort`,
2171
+ ),
2172
+ }),
2092
2173
  ...(agent.instruction === undefined
2093
2174
  ? {}
2094
2175
  : { instruction: agent.instruction }),
@@ -2114,6 +2195,12 @@ export function projectHostAgent(agent, path = "agent") {
2114
2195
  ...(normalized.fastMode === undefined
2115
2196
  ? {}
2116
2197
  : { fastMode: normalized.fastMode }),
2198
+ ...(normalized.subagentModel === undefined
2199
+ ? {}
2200
+ : { subagentModel: normalized.subagentModel }),
2201
+ ...(normalized.subagentEffort === undefined
2202
+ ? {}
2203
+ : { subagentEffort: normalized.subagentEffort }),
2117
2204
  ...(normalized.instruction === undefined
2118
2205
  ? {}
2119
2206
  : { instruction: normalized.instruction }),
@@ -2130,6 +2217,13 @@ function fastModeSelection(value, path) {
2130
2217
  return value;
2131
2218
  }
2132
2219
 
2220
+ function nonblankSelection(value, path) {
2221
+ if (typeof value !== "string" || value.trim().length === 0) {
2222
+ throw new Error(`${path} must be a nonblank string`);
2223
+ }
2224
+ return value;
2225
+ }
2226
+
2133
2227
  function tuningSelection(value, path) {
2134
2228
  if (
2135
2229
  value !== undefined &&
@@ -966,9 +966,12 @@ function helpText({
966
966
  " - set the top-level captain and each stable players.<id> to an",
967
967
  " adapter shorthand (claude, codex) or an inline agent block",
968
968
  " - bind every playbooks.<id>.roles.<role> explicitly to a player id;",
969
- " a scalar names the id, while { player, model?, effort?, fastMode? }",
970
- " may retune one role; false selects provider-default model/effort,",
971
- " while fastMode false is a literal disabled request",
969
+ " a scalar names the id, while { player, model?, effort?, fastMode?,",
970
+ " subagentModel?, subagentEffort? } may retune one role;",
971
+ " false selects provider-default model/effort/subagentModel/",
972
+ " subagentEffort, while fastMode false is a literal disabled request",
973
+ " - a Claude agent delegates by default: subagentModel resolves to",
974
+ " inherit (its own model) unless the block sets false",
972
975
  " - reusing one id deliberately shares that provider conversation;",
973
976
  " distinct ids stay isolated even when their agent blocks are equal",
974
977
  " - the launcher injects captain.from and retains referenced player ids",
@@ -11,7 +11,10 @@ import { attachSessionHints, validateSessionContext } from "./portable-codec.js"
11
11
  import { homedir } from "node:os";
12
12
  import { resolve } from "node:path";
13
13
  import { isDeepStrictEqual } from "node:util";
14
- import { assertFastModeSupported } from "@sublang/cligent";
14
+ import {
15
+ assertFastModeSupported,
16
+ assertSubagentModelSupported,
17
+ } from "@sublang/cligent";
15
18
  import { createTmuxPlayRuntime } from "@sublang/cligent/tmux-play";
16
19
  import {
17
20
  assertPlaybookEffectLedger,
@@ -1307,7 +1310,7 @@ export async function validateFrozenExecutionConfig(
1307
1310
  structuralProjection,
1308
1311
  executionProjection,
1309
1312
  );
1310
- assertFrozenFastModesSupported(config);
1313
+ assertFrozenTuningSupported(config);
1311
1314
  const catalogItems = Object.entries(config.catalog);
1312
1315
 
1313
1316
  // Preserve the complete-catalog preparation transaction: prepare every
@@ -1364,28 +1367,45 @@ export async function validateFrozenExecutionConfig(
1364
1367
  return config;
1365
1368
  }
1366
1369
 
1367
- function assertFrozenFastModesSupported(config) {
1368
- if (config.captain.fastMode !== undefined) {
1369
- assertFastModeSupported(
1370
- config.captain.adapter,
1371
- "stored Captain fastMode",
1372
- );
1373
- }
1370
+ // Fast mode, the subagent model and the subagent effort are adapter-scoped
1371
+ // tuning (DR-075, DR-076): a frozen record re-validates each present value
1372
+ // against its adapter. Cligent serves the subagent effort exactly where it
1373
+ // serves a subagent model, so one capability assertion covers both.
1374
+ const FROZEN_ADAPTER_SCOPED_TUNING = [
1375
+ ["fastMode", assertFastModeSupported],
1376
+ ["subagentModel", assertSubagentModelSupported],
1377
+ ["subagentEffort", assertSubagentModelSupported],
1378
+ ];
1379
+
1380
+ function assertFrozenTuningSupported(config) {
1381
+ const assertSupported = (record, adapter, path) => {
1382
+ for (const [field, assertFieldSupported] of FROZEN_ADAPTER_SCOPED_TUNING) {
1383
+ if (record[field] !== undefined) {
1384
+ assertFieldSupported(adapter, `${path} ${field}`);
1385
+ }
1386
+ }
1387
+ };
1388
+ assertSupported(config.captain, config.captain.adapter, "stored Captain");
1374
1389
 
1375
1390
  const playerAdapters = new Map();
1376
1391
  for (const player of config.players) {
1377
1392
  playerAdapters.set(player.id, player.adapter);
1378
- if (player.fastMode !== undefined) {
1379
- assertFastModeSupported(
1380
- player.adapter,
1381
- `stored player ${JSON.stringify(player.id)} fastMode`,
1382
- );
1383
- }
1393
+ assertSupported(
1394
+ player,
1395
+ player.adapter,
1396
+ `stored player ${JSON.stringify(player.id)}`,
1397
+ );
1384
1398
  }
1385
1399
 
1386
1400
  for (const [playbookId, item] of Object.entries(config.catalog)) {
1387
1401
  for (const [roleId, binding] of Object.entries(item.roles)) {
1388
- if (binding.fastMode === undefined) continue;
1402
+ if (
1403
+ FROZEN_ADAPTER_SCOPED_TUNING.every(
1404
+ ([field]) => binding[field] === undefined,
1405
+ )
1406
+ ) {
1407
+ continue;
1408
+ }
1389
1409
  const adapter = playerAdapters.get(binding.playerId);
1390
1410
  if (adapter === undefined) {
1391
1411
  throw new Error(
@@ -1393,10 +1413,11 @@ function assertFrozenFastModesSupported(config) {
1393
1413
  `${JSON.stringify(roleId)} names an unknown player`,
1394
1414
  );
1395
1415
  }
1396
- assertFastModeSupported(
1416
+ assertSupported(
1417
+ binding,
1397
1418
  adapter,
1398
1419
  `stored playbook ${JSON.stringify(playbookId)} role ` +
1399
- `${JSON.stringify(roleId)} fastMode`,
1420
+ `${JSON.stringify(roleId)}`,
1400
1421
  );
1401
1422
  }
1402
1423
  }
@@ -1869,7 +1890,7 @@ function runHelpText(userConfigPath) {
1869
1890
  "working directory and reports when it uses the global newest fallback.",
1870
1891
  "An ordinary continued run restores that stored structure and working",
1871
1892
  "directory, then reads current config and overlays for model, effort,",
1872
- "and fast mode.",
1893
+ "fast mode, subagent model, and subagent effort.",
1873
1894
  "Uncertain retry only restores and reports; later work uses current settings.",
1874
1895
  "",
1875
1896
  "Options:",
@@ -5402,7 +5402,13 @@ function validateCaptainSessionProjection(
5402
5402
  : ['id', 'adapter', 'model', 'effort'],
5403
5403
  structural
5404
5404
  ? ['instruction', 'permissions']
5405
- : ['instruction', 'permissions', 'fastMode'],
5405
+ : [
5406
+ 'instruction',
5407
+ 'permissions',
5408
+ 'fastMode',
5409
+ 'subagentModel',
5410
+ 'subagentEffort',
5411
+ ],
5406
5412
  playerPath,
5407
5413
  );
5408
5414
  assertPlayerId(player.id, `${playerPath}.id`);
@@ -5503,12 +5509,13 @@ function validateCaptainSessionProjection(
5503
5509
  structural
5504
5510
  ? ['playerId']
5505
5511
  : ['playerId', 'model', 'effort'],
5506
- structural ? [] : ['fastMode'],
5512
+ structural ? [] : ['fastMode', 'subagentModel', 'subagentEffort'],
5507
5513
  bindingPath,
5508
5514
  );
5509
5515
  assertPlayerId(binding.playerId, `${bindingPath}.playerId`);
5510
5516
  if (!structural) {
5511
5517
  validateProjectedFastMode(binding, `${bindingPath}.fastMode`);
5518
+ validateProjectedSubagentTuning(binding, bindingPath);
5512
5519
  validateTuningSelection(binding.model, `${bindingPath}.model`);
5513
5520
  const player = projection.players.find(
5514
5521
  (candidate) => candidate.id === binding.playerId,
@@ -5556,16 +5563,36 @@ function validateProjectedFastMode(record, path) {
5556
5563
  }
5557
5564
  }
5558
5565
 
5566
+ // Like fast mode, absence is the canonical provider default (DR-075); the
5567
+ // subagent model may be the literal `inherit`, and an absent subagent effort
5568
+ // leaves each subagent's effort to the agent (DR-076).
5569
+ function validateProjectedSubagentTuning(record, path) {
5570
+ for (const field of ['subagentModel', 'subagentEffort']) {
5571
+ if (Object.hasOwn(record, field)) {
5572
+ requireNonblank(record[field], `${path}.${field}`);
5573
+ }
5574
+ }
5575
+ }
5576
+
5559
5577
  function validateProjectedAgent(value, path, { structural, hasId = false }) {
5560
5578
  const agent = requireRecord(value, path);
5561
- if (!structural) validateProjectedFastMode(agent, `${path}.fastMode`);
5579
+ if (!structural) {
5580
+ validateProjectedFastMode(agent, `${path}.fastMode`);
5581
+ validateProjectedSubagentTuning(agent, path);
5582
+ }
5562
5583
  if (!hasId) {
5563
5584
  exactOptionalKeys(
5564
5585
  agent,
5565
5586
  structural ? ['adapter'] : ['adapter', 'model', 'effort'],
5566
5587
  structural
5567
5588
  ? ['instruction', 'permissions']
5568
- : ['instruction', 'permissions', 'fastMode'],
5589
+ : [
5590
+ 'instruction',
5591
+ 'permissions',
5592
+ 'fastMode',
5593
+ 'subagentModel',
5594
+ 'subagentEffort',
5595
+ ],
5569
5596
  path,
5570
5597
  );
5571
5598
  }
@@ -10,9 +10,17 @@ interface SessionAgent {
10
10
  /** Adapter-scoped fast mode. Absence is the provider default; `false` is a
11
11
  * literal request, so this carries no provider-default sentinel. */
12
12
  readonly fastMode?: boolean;
13
+ /** The model every subagent of this agent runs on (DR-075), or `inherit`
14
+ * for the agent's own model (DR-076). Absence is the provider default. */
15
+ readonly subagentModel?: string;
16
+ /** The effort every subagent of this agent runs at (DR-076). Absence leaves
17
+ * each subagent's effort to the agent. */
18
+ readonly subagentEffort?: string;
13
19
  readonly instruction?: string;
14
20
  readonly permissions?: PermissionPolicy;
15
21
  }
22
+ /** Per-call tuning: erased from every structural envelope and snapshot. */
23
+ type TuningKey = 'model' | 'effort' | 'fastMode' | 'subagentModel' | 'subagentEffort';
16
24
  interface PlayerLedgerEntry {
17
25
  readonly adapter: string;
18
26
  readonly instruction?: string;
@@ -32,7 +40,7 @@ interface PlaybookCaptainUnresolvedEffectSettlementInput {
32
40
  readonly rootPlaybookId: string;
33
41
  readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
34
42
  }
35
- type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, 'model' | 'effort' | 'fastMode'>>;
43
+ type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey>>;
36
44
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
37
45
  export interface ProgressChange {
38
46
  snapshot?: PlaybookCaptainShellSnapshot | null;
@@ -2098,10 +2098,30 @@ function snapshotFastMode(value, path) {
2098
2098
  }
2099
2099
  return value;
2100
2100
  }
2101
+ function snapshotNonblankTuning(value, path) {
2102
+ if (value !== undefined &&
2103
+ (typeof value !== 'string' || value.trim().length === 0)) {
2104
+ throw new TypeError(`${path} must be a nonblank string`);
2105
+ }
2106
+ return value;
2107
+ }
2101
2108
  function snapshotSessionAgent(value, path) {
2102
2109
  const agent = snapshotRecord(value, path);
2103
- rejectSnapshotKeys(agent, ['adapter', 'model', 'effort', 'fastMode', 'instruction', 'permissions'], path);
2104
- const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(agent).filter(([key]) => key !== 'model' && key !== 'effort' && key !== 'fastMode')), path);
2110
+ rejectSnapshotKeys(agent, [
2111
+ 'adapter',
2112
+ 'model',
2113
+ 'effort',
2114
+ 'fastMode',
2115
+ 'subagentModel',
2116
+ 'subagentEffort',
2117
+ 'instruction',
2118
+ 'permissions',
2119
+ ], path);
2120
+ const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(agent).filter(([key]) => key !== 'model' &&
2121
+ key !== 'effort' &&
2122
+ key !== 'fastMode' &&
2123
+ key !== 'subagentModel' &&
2124
+ key !== 'subagentEffort')), path);
2105
2125
  return {
2106
2126
  adapter: fixed.adapter,
2107
2127
  ...(fixed.instruction === undefined
@@ -2115,6 +2135,16 @@ function snapshotSessionAgent(value, path) {
2115
2135
  ...(agent.fastMode === undefined
2116
2136
  ? {}
2117
2137
  : { fastMode: snapshotFastMode(agent.fastMode, `${path}.fastMode`) }),
2138
+ ...(agent.subagentModel === undefined
2139
+ ? {}
2140
+ : {
2141
+ subagentModel: snapshotNonblankTuning(agent.subagentModel, `${path}.subagentModel`),
2142
+ }),
2143
+ ...(agent.subagentEffort === undefined
2144
+ ? {}
2145
+ : {
2146
+ subagentEffort: snapshotNonblankTuning(agent.subagentEffort, `${path}.subagentEffort`),
2147
+ }),
2118
2148
  };
2119
2149
  }
2120
2150
  function fixedAgent(agent) {
@@ -2126,12 +2156,20 @@ function fixedAgent(agent) {
2126
2156
  }
2127
2157
  function callSettings(agent, tuning = agent) {
2128
2158
  // cligent treats supplied call settings as a complete replacement, so an
2129
- // omitted fastMode here is a request for the provider default, never an
2130
- // inheritance of whatever the previous call left behind.
2159
+ // omitted fastMode or subagentModel here is a request for the provider
2160
+ // default, and an omitted subagentEffort leaves each subagent's effort to
2161
+ // the agent — never an inheritance of whatever the previous call left
2162
+ // behind.
2131
2163
  return {
2132
2164
  model: tuning.model,
2133
2165
  effort: tuning.effort,
2134
2166
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
2167
+ ...(tuning.subagentModel === undefined
2168
+ ? {}
2169
+ : { subagentModel: tuning.subagentModel }),
2170
+ ...(tuning.subagentEffort === undefined
2171
+ ? {}
2172
+ : { subagentEffort: tuning.subagentEffort }),
2135
2173
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
2136
2174
  ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
2137
2175
  };
@@ -2259,7 +2297,14 @@ async function buildEnablements(options, loadModule, hostCapabilities) {
2259
2297
  for (const role of entry.requiredRoleIds) {
2260
2298
  const path = `captain.options.playbooks.${id}.roles.${role}`;
2261
2299
  const rawBinding = snapshotRecord(roleRecord[role], path);
2262
- rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort', 'fastMode'], path);
2300
+ rejectSnapshotKeys(rawBinding, [
2301
+ 'playerId',
2302
+ 'model',
2303
+ 'effort',
2304
+ 'fastMode',
2305
+ 'subagentModel',
2306
+ 'subagentEffort',
2307
+ ], path);
2263
2308
  const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
2264
2309
  if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
2265
2310
  throw new Error(`${path}.playerId is not a canonical player id`);
@@ -2277,6 +2322,16 @@ async function buildEnablements(options, loadModule, hostCapabilities) {
2277
2322
  : {
2278
2323
  fastMode: snapshotFastMode(rawBinding.fastMode, `${path}.fastMode`),
2279
2324
  }),
2325
+ ...(rawBinding.subagentModel === undefined
2326
+ ? {}
2327
+ : {
2328
+ subagentModel: snapshotNonblankTuning(rawBinding.subagentModel, `${path}.subagentModel`),
2329
+ }),
2330
+ ...(rawBinding.subagentEffort === undefined
2331
+ ? {}
2332
+ : {
2333
+ subagentEffort: snapshotNonblankTuning(rawBinding.subagentEffort, `${path}.subagentEffort`),
2334
+ }),
2280
2335
  agent,
2281
2336
  });
2282
2337
  }
@@ -69,10 +69,24 @@ interface SessionAgent {
69
69
  /** Adapter-scoped fast mode. Absence is the provider default; `false` is a
70
70
  * literal request, so this carries no provider-default sentinel. */
71
71
  readonly fastMode?: boolean;
72
+ /** The model every subagent of this agent runs on (DR-075), or `inherit`
73
+ * for the agent's own model (DR-076). Absence is the provider default. */
74
+ readonly subagentModel?: string;
75
+ /** The effort every subagent of this agent runs at (DR-076). Absence leaves
76
+ * each subagent's effort to the agent. */
77
+ readonly subagentEffort?: string;
72
78
  readonly instruction?: string;
73
79
  readonly permissions?: PermissionPolicy;
74
80
  }
75
81
 
82
+ /** Per-call tuning: erased from every structural envelope and snapshot. */
83
+ type TuningKey =
84
+ | 'model'
85
+ | 'effort'
86
+ | 'fastMode'
87
+ | 'subagentModel'
88
+ | 'subagentEffort';
89
+
76
90
  interface PlayerLedgerEntry {
77
91
  readonly adapter: string;
78
92
  readonly instruction?: string;
@@ -107,9 +121,7 @@ interface PlaybookCaptainUnresolvedEffectSettlementInput {
107
121
  readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
108
122
  }
109
123
 
110
- type SnapshotAgentEnvelope = DeepReadonly<
111
- Omit<SessionAgent, 'model' | 'effort' | 'fastMode'>
112
- >;
124
+ type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey>>;
113
125
 
114
126
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
115
127
 
@@ -447,6 +459,8 @@ interface EffectivePlayerBinding {
447
459
  readonly model: TuningSelection;
448
460
  readonly effort: TuningSelection<Effort>;
449
461
  readonly fastMode?: boolean;
462
+ readonly subagentModel?: string;
463
+ readonly subagentEffort?: string;
450
464
  readonly agent: SessionAgent;
451
465
  }
452
466
 
@@ -3722,6 +3736,19 @@ function snapshotFastMode(
3722
3736
  return value;
3723
3737
  }
3724
3738
 
3739
+ function snapshotNonblankTuning(
3740
+ value: JsonValue | undefined,
3741
+ path: string,
3742
+ ): string | undefined {
3743
+ if (
3744
+ value !== undefined &&
3745
+ (typeof value !== 'string' || value.trim().length === 0)
3746
+ ) {
3747
+ throw new TypeError(`${path} must be a nonblank string`);
3748
+ }
3749
+ return value;
3750
+ }
3751
+
3725
3752
  function snapshotSessionAgent(
3726
3753
  value: JsonValue | undefined,
3727
3754
  path: string,
@@ -3729,14 +3756,27 @@ function snapshotSessionAgent(
3729
3756
  const agent = snapshotRecord(value, path);
3730
3757
  rejectSnapshotKeys(
3731
3758
  agent,
3732
- ['adapter', 'model', 'effort', 'fastMode', 'instruction', 'permissions'],
3759
+ [
3760
+ 'adapter',
3761
+ 'model',
3762
+ 'effort',
3763
+ 'fastMode',
3764
+ 'subagentModel',
3765
+ 'subagentEffort',
3766
+ 'instruction',
3767
+ 'permissions',
3768
+ ],
3733
3769
  path,
3734
3770
  );
3735
3771
  const fixed = snapshotFixedAgent(
3736
3772
  Object.fromEntries(
3737
3773
  Object.entries(agent).filter(
3738
3774
  ([key]) =>
3739
- key !== 'model' && key !== 'effort' && key !== 'fastMode',
3775
+ key !== 'model' &&
3776
+ key !== 'effort' &&
3777
+ key !== 'fastMode' &&
3778
+ key !== 'subagentModel' &&
3779
+ key !== 'subagentEffort',
3740
3780
  ),
3741
3781
  ) as JsonValue,
3742
3782
  path,
@@ -3754,12 +3794,26 @@ function snapshotSessionAgent(
3754
3794
  ...(agent.fastMode === undefined
3755
3795
  ? {}
3756
3796
  : { fastMode: snapshotFastMode(agent.fastMode, `${path}.fastMode`) }),
3797
+ ...(agent.subagentModel === undefined
3798
+ ? {}
3799
+ : {
3800
+ subagentModel: snapshotNonblankTuning(
3801
+ agent.subagentModel,
3802
+ `${path}.subagentModel`,
3803
+ ),
3804
+ }),
3805
+ ...(agent.subagentEffort === undefined
3806
+ ? {}
3807
+ : {
3808
+ subagentEffort: snapshotNonblankTuning(
3809
+ agent.subagentEffort,
3810
+ `${path}.subagentEffort`,
3811
+ ),
3812
+ }),
3757
3813
  };
3758
3814
  }
3759
3815
 
3760
- function fixedAgent(
3761
- agent: SessionAgent,
3762
- ): Omit<SessionAgent, 'model' | 'effort' | 'fastMode'> {
3816
+ function fixedAgent(agent: SessionAgent): Omit<SessionAgent, TuningKey> {
3763
3817
  return {
3764
3818
  adapter: agent.adapter,
3765
3819
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
@@ -3769,15 +3823,23 @@ function fixedAgent(
3769
3823
 
3770
3824
  function callSettings(
3771
3825
  agent: SessionAgent,
3772
- tuning: Pick<SessionAgent, 'model' | 'effort' | 'fastMode'> = agent,
3826
+ tuning: Pick<SessionAgent, TuningKey> = agent,
3773
3827
  ): AgentCallSettings {
3774
3828
  // cligent treats supplied call settings as a complete replacement, so an
3775
- // omitted fastMode here is a request for the provider default, never an
3776
- // inheritance of whatever the previous call left behind.
3829
+ // omitted fastMode or subagentModel here is a request for the provider
3830
+ // default, and an omitted subagentEffort leaves each subagent's effort to
3831
+ // the agent — never an inheritance of whatever the previous call left
3832
+ // behind.
3777
3833
  return {
3778
3834
  model: tuning.model,
3779
3835
  effort: tuning.effort,
3780
3836
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
3837
+ ...(tuning.subagentModel === undefined
3838
+ ? {}
3839
+ : { subagentModel: tuning.subagentModel }),
3840
+ ...(tuning.subagentEffort === undefined
3841
+ ? {}
3842
+ : { subagentEffort: tuning.subagentEffort as Effort }),
3781
3843
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
3782
3844
  ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
3783
3845
  };
@@ -4005,7 +4067,14 @@ async function buildEnablements(
4005
4067
  const rawBinding = snapshotRecord(roleRecord[role], path);
4006
4068
  rejectSnapshotKeys(
4007
4069
  rawBinding,
4008
- ['playerId', 'model', 'effort', 'fastMode'],
4070
+ [
4071
+ 'playerId',
4072
+ 'model',
4073
+ 'effort',
4074
+ 'fastMode',
4075
+ 'subagentModel',
4076
+ 'subagentEffort',
4077
+ ],
4009
4078
  path,
4010
4079
  );
4011
4080
  const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
@@ -4030,6 +4099,22 @@ async function buildEnablements(
4030
4099
  `${path}.fastMode`,
4031
4100
  ),
4032
4101
  }),
4102
+ ...(rawBinding.subagentModel === undefined
4103
+ ? {}
4104
+ : {
4105
+ subagentModel: snapshotNonblankTuning(
4106
+ rawBinding.subagentModel,
4107
+ `${path}.subagentModel`,
4108
+ ),
4109
+ }),
4110
+ ...(rawBinding.subagentEffort === undefined
4111
+ ? {}
4112
+ : {
4113
+ subagentEffort: snapshotNonblankTuning(
4114
+ rawBinding.subagentEffort,
4115
+ `${path}.subagentEffort`,
4116
+ ),
4117
+ }),
4033
4118
  agent,
4034
4119
  });
4035
4120
  }
@@ -8,10 +8,15 @@
8
8
 
9
9
  # The Captain and each stable session player carries its own defaults inline:
10
10
  # an adapter shorthand (claude, codex) or a block with
11
- # adapter/model/effort/fastMode/permissions. A role may override only model,
12
- # effort, or fastMode.
13
- # Omit an override to inherit the player default. For model and effort, false
14
- # selects the provider default; fastMode false is a literal disabled request.
11
+ # adapter/model/effort/fastMode/subagentModel/subagentEffort/permissions. A
12
+ # role may override only model, effort, fastMode, subagentModel, or
13
+ # subagentEffort.
14
+ # Omit an override to inherit the player default. For model, effort,
15
+ # subagentModel, and subagentEffort, false selects the provider default;
16
+ # fastMode false is a literal disabled request.
17
+ # A Claude agent delegates by default: its subagents run on its own model
18
+ # (subagentModel: inherit) at efforts it chooses per task; subagentModel: false
19
+ # on its block switches delegation off.
15
20
 
16
21
  # Each adapter needs its vendor SDK installed as its own top-level
17
22
  # install root — they are optional peer dependencies, so you pay only