@signalridge/pi-subagents 1.8.0 → 1.9.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/src/index.ts CHANGED
@@ -86,17 +86,22 @@ import {
86
86
  buildAgentTierListText,
87
87
  buildAgentTierParameterDescription,
88
88
  buildCompactAgentTierListText,
89
+ type DefaultAgentTierSelection,
89
90
  findUnknownAgentTierReferences,
90
91
  getAgentTiersConfiguredSettings,
91
92
  getAgentTiersSettings,
93
+ getDefaultAgentTierSelection,
92
94
  getDefaultAgentTierText,
95
+ getRoutingPolicySnapshot,
93
96
  isValidAgentTierKey,
94
97
  listAgentTierKeys,
95
98
  MAX_AGENT_TIER_KEY_LENGTH,
96
99
  offerableTierThinking,
97
100
  removeAgentTierProfile,
101
+ selectAgentTier,
98
102
  setAgentTiersSettings,
99
103
  setDefaultAgentTier,
104
+ shippedFallbackAgentTier,
100
105
  upsertAgentTierProfile,
101
106
  } from "./agent-tiers.js";
102
107
  import {
@@ -139,7 +144,7 @@ import {
139
144
  stripAgentPrefix,
140
145
  } from "./mention.js";
141
146
  import { runMentionClone } from "./mention-clone.js";
142
- import { type ModelRegistry, resolveModel } from "./model-resolver.js";
147
+ import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resolver.js";
143
148
  import {
144
149
  checkModelScope,
145
150
  isScopeModelsEnabled,
@@ -222,7 +227,6 @@ import {
222
227
  getSessionContextPercent,
223
228
  type LifetimeUsage,
224
229
  } from "./usage.js";
225
- import { getWorkflowSettings, resolveWorkflowTier, setWorkflowSettings } from "./workflow-tiers.js";
226
230
  import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
227
231
 
228
232
  // ---- Shared helpers ----
@@ -800,6 +804,8 @@ function activateRootRuntime(
800
804
  durationMs,
801
805
  tokens,
802
806
  ...(record.outputFile ? { outputFile: record.outputFile } : {}),
807
+ ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
808
+ ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: record.invocation.agentTierSnapshot } : {}),
803
809
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
804
810
  };
805
811
  }
@@ -836,6 +842,8 @@ function activateRootRuntime(
836
842
  ...(terminal?.tokenCount
837
843
  ? { tokens: { total: terminal.tokenCount } }
838
844
  : {}),
845
+ ...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
846
+ ...(tombstone.tierSnapshot ? { tierSnapshot: tombstone.tierSnapshot } : {}),
839
847
  owner: snapshotOwner(tombstone.owner),
840
848
  };
841
849
  }
@@ -859,6 +867,8 @@ function activateRootRuntime(
859
867
  compactionCount: terminal.compactionCount,
860
868
  ...(terminal.outputFile ? { outputFile: terminal.outputFile } : {}),
861
869
  ...(terminal.tokenCount ? { tokens: terminal.tokenCount } : {}),
870
+ ...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
871
+ ...(tombstone.tierSnapshot ? { tierSnapshot: tombstone.tierSnapshot } : {}),
862
872
  owner: snapshotOwner(tombstone.owner),
863
873
  });
864
874
  }
@@ -924,6 +934,8 @@ function activateRootRuntime(
924
934
  ...(record.lifetimeUsage.input + record.lifetimeUsage.output > 0
925
935
  ? { tokens: record.lifetimeUsage.input + record.lifetimeUsage.output }
926
936
  : {}),
937
+ ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
938
+ ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: record.invocation.agentTierSnapshot } : {}),
927
939
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
928
940
  });
929
941
 
@@ -968,6 +980,7 @@ function activateRootRuntime(
968
980
  id: record.id,
969
981
  type: record.type,
970
982
  description: record.description,
983
+ ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
971
984
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
972
985
  });
973
986
  },
@@ -981,6 +994,8 @@ function activateRootRuntime(
981
994
  reason: info.reason,
982
995
  tokensBefore: info.tokensBefore,
983
996
  compactionCount: record.compactionCount,
997
+ ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
998
+ ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: record.invocation.agentTierSnapshot } : {}),
984
999
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
985
1000
  });
986
1001
  },
@@ -994,6 +1009,7 @@ function activateRootRuntime(
994
1009
  type: record.type,
995
1010
  description: record.description,
996
1011
  isBackground: record.isBackground,
1012
+ ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
997
1013
  owner: snapshotOwner(record.owner),
998
1014
  });
999
1015
  },
@@ -1224,6 +1240,7 @@ function activateRootRuntime(
1224
1240
  pi.events.emit("subagents:ready", {
1225
1241
  version: PROTOCOL_VERSION,
1226
1242
  capabilities: PROTOCOL_CAPABILITIES,
1243
+ routingPolicy: getRoutingPolicySnapshot(),
1227
1244
  });
1228
1245
  }
1229
1246
  // `@` completion for agent handles. Registered at most ONCE for the
@@ -1422,14 +1439,6 @@ function activateRootRuntime(
1422
1439
  fleet.ensureTimer();
1423
1440
  }
1424
1441
 
1425
- const splitManagedModelSpec = (spec: string): { model: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max" } => {
1426
- const separator = spec.lastIndexOf(":");
1427
- const suffix = separator > spec.indexOf("/") ? spec.slice(separator + 1) : "";
1428
- const thinking = ["minimal", "low", "medium", "high", "xhigh", "max"].includes(suffix)
1429
- ? (suffix as "minimal" | "low" | "medium" | "high" | "xhigh" | "max")
1430
- : undefined;
1431
- return thinking ? { model: spec.slice(0, separator), thinking } : { model: spec };
1432
- };
1433
1442
  /**
1434
1443
  * Add the same activity, transcript, and FleetView wiring used by Agent-tool
1435
1444
  * background runs without expanding the managed RPC's policy surface.
@@ -1446,90 +1455,55 @@ function activateRootRuntime(
1446
1455
  reloadCustomAgents();
1447
1456
  const dispatch = resolveSpawnType(request.type);
1448
1457
  if (!dispatch.ok) throw new Error(dispatch.message);
1449
- const workflowSettings = getWorkflowSettings();
1450
- if (request.tier === undefined && workflowSettings.blockedDefaultTier) {
1451
- throw new Error(
1452
- "workflow defaultTier is blocked by malformed configuration",
1453
- );
1454
- }
1455
- const effectiveTier = request.tier ?? workflowSettings.defaultTier;
1456
- const normalizedRequest = {
1457
- ...request,
1458
- type: dispatch.type,
1459
- ...(effectiveTier === undefined ? {} : { tier: effectiveTier }),
1460
- };
1461
1458
  const customConfig = getAgentConfig(dispatch.type);
1462
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, {
1463
- workflowTier: effectiveTier,
1464
- });
1465
- const requestedModel = request.model ? splitManagedModelSpec(request.model) : undefined;
1466
- const requestedThinking = request.thinking === "off" ? undefined : request.thinking;
1467
- const parentThinking = (() => {
1468
- const level = (piRef as unknown as { getThinkingLevel?: () => unknown }).getThinkingLevel?.();
1469
- return level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max"
1470
- ? level
1471
- : undefined;
1459
+ const agentTiers = getAgentTiersSettings();
1460
+ // Materialize only the *identity* of the tier this call will land on, so a
1461
+ // tombstone and the lifecycle events carry it before runAgent's resolution
1462
+ // callback fires. runAgent stays authoritative for source, model, thinking,
1463
+ // and the snapshot — this is a label, not a decision.
1464
+ //
1465
+ // Asked, not restated: the same `selectAgentTier` the runner uses, with the
1466
+ // same `requireTier` the manager will set, so the label cannot name a tier
1467
+ // other than the one that runs. A fail-closed condition throws here; the
1468
+ // runner owns that refusal and its message, so the label is simply absent
1469
+ // and the spawn fails there with the diagnostic that names the cause.
1470
+ const effectiveTier = (() => {
1471
+ try {
1472
+ return selectAgentTier(
1473
+ { requestedTier: request.tier, requireTier: true, agentConfig: customConfig },
1474
+ agentTiers,
1475
+ )?.tier;
1476
+ } catch {
1477
+ return undefined;
1478
+ }
1472
1479
  })();
1473
- const tierResolution = effectiveTier
1474
- ? resolveWorkflowTier({
1475
- tier: effectiveTier,
1476
- agentConfig: customConfig,
1477
- modelOverride: requestedModel?.model,
1478
- thinkingOverride: requestedThinking,
1479
- parentModel: ctxRef.model,
1480
- parentThinking,
1481
- modelRegistry: ctxRef.modelRegistry,
1482
- })
1483
- : undefined;
1484
- const resolvedRequestedModel = requestedModel
1485
- ? resolveModel(requestedModel.model, ctxRef.modelRegistry)
1486
- : undefined;
1487
- if (typeof resolvedRequestedModel === "string") throw new Error(resolvedRequestedModel);
1488
- const modelInput = requestedModel?.model ?? resolvedConfig.modelInput;
1489
- // An exact managed model is still checked by pi-subagents' model-scope policy;
1490
- // workflows never bypass the host's enabled-model restrictions.
1491
- if (effectiveTier === undefined || requestedModel !== undefined) {
1492
- const model = resolvedRequestedModel ?? resolveConfiguredDefaultModel(ctxRef.modelRegistry) ?? ctxRef.model;
1493
- const scopeVerdict = checkModelScope({
1494
- model,
1495
- cwd: ctxRef.cwd,
1496
- modelRegistry: ctxRef.modelRegistry,
1497
- callerSupplied: requestedModel !== undefined,
1498
- agentLabel: customConfig?.displayName ?? dispatch.type,
1499
- modelInput,
1500
- });
1501
- if (scopeVerdict.kind === "error") throw new Error(scopeVerdict.message);
1502
- if (scopeVerdict.kind === "warn" && ctxRef.hasUI) ctxRef.ui.notify(scopeVerdict.message, "warning");
1503
- }
1480
+ // `request.tier` already rides the spread; the only field this changes is
1481
+ // the agent type, which `resolveSpawnType` may have redirected.
1482
+ const normalizedRequest = { ...request, type: dispatch.type };
1483
+ // Only the non-tier fields are read here. The manager marks every managed
1484
+ // spawn `requireAgentTier`, so a tier always owns model and thinking; asking
1485
+ // `resolveAgentInvocationConfig` to decide that again would be work whose
1486
+ // answer this path discards.
1487
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, {});
1488
+ // Model and thinking are resolved only by the runner's tier path. There is
1489
+ // no second selector on this wire, so nothing here can silently win or be
1490
+ // silently dropped; a call with no tier and no default fails closed.
1504
1491
  const effectiveMaxTurns = normalizeMaxTurns(
1505
1492
  resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
1506
1493
  );
1507
1494
  const effectiveIsolation = request.isolation ?? resolvedConfig.isolation;
1508
- const configuredModel =
1509
- resolvedConfig.modelInput && resolvedConfig.modelInput !== "inherit"
1510
- ? resolveModel(resolvedConfig.modelInput, ctxRef.modelRegistry)
1511
- : undefined;
1512
- const effectiveModel =
1513
- resolvedRequestedModel ??
1514
- tierResolution?.model ??
1515
- (typeof configuredModel === "string" ? undefined : configuredModel) ??
1516
- (effectiveTier === undefined ? resolveConfiguredDefaultModel(ctxRef.modelRegistry) ?? ctxRef.model : undefined);
1517
1495
  const effectiveExcludeTools = [...new Set([...(customConfig?.disallowedTools ?? []), ...(request.excludeTools ?? [])])];
1518
1496
  const managedPolicy: ManagedSpawnPolicy = {
1519
- // The managed request may select an exact model, but resolution and scope
1520
- // checks above remain owned by pi-subagents.
1521
- model: effectiveModel,
1522
1497
  maxTurns: effectiveMaxTurns,
1523
1498
  isolated: resolvedConfig.isolated,
1524
1499
  inheritContext: resolvedConfig.inheritContext,
1525
- thinkingLevel: (tierResolution?.thinkingLevel ?? requestedThinking ?? resolvedConfig.thinking ?? parentThinking ?? undefined) as "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | undefined,
1526
1500
  isolation: effectiveIsolation,
1527
1501
  toolset: request.toolset,
1528
1502
  excludeTools: effectiveExcludeTools,
1529
1503
  policyFingerprint: JSON.stringify(customConfig ?? null),
1530
1504
  rootSessionId: ctxRef.sessionManager.getSessionId(),
1531
1505
  invocation: {
1532
- tier: effectiveTier,
1506
+ ...(effectiveTier === undefined ? {} : { agentTier: effectiveTier }),
1533
1507
  maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
1534
1508
  isolated: resolvedConfig.isolated,
1535
1509
  inheritContext: resolvedConfig.inheritContext,
@@ -1901,7 +1875,6 @@ function activateRootRuntime(
1901
1875
  manager.setMaxSubagentSpawnsPerBranch(n),
1902
1876
  setFallbackSubagent,
1903
1877
  setWorktreeIsolation: setWorktreeIsolationEnabled,
1904
- setWorkflow: setWorkflowSettings,
1905
1878
  setAgentTiers: setAgentTiersSettings,
1906
1879
  });
1907
1880
  pi.events.emit("subagents:settings_loaded", { settings: startupSettings });
@@ -2551,18 +2524,18 @@ Terse command-style prompts produce shallow, generic work.
2551
2524
  // Get agent config (if any)
2552
2525
  const customConfig = getAgentConfig(subagentType);
2553
2526
 
2554
- const resolvedConfig = resolveAgentInvocationConfig(
2555
- customConfig,
2556
- params,
2557
- );
2527
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, {
2528
+ ...params,
2529
+ agentTiers: getAgentTiersSettings(),
2530
+ });
2558
2531
 
2559
- // Resolve model from agent config first; tool-call params only fill gaps.
2560
- // With neither, runAgent falls to the configured `defaultModel` before the
2561
- // parent, so mirror that here — the scope check below and the model label
2562
- // must describe the model that will actually run.
2563
- let model =
2564
- resolveConfiguredDefaultModel(ctx.modelRegistry) ?? ctx.model;
2565
- if (resolvedConfig.modelInput) {
2532
+ // A selected Agent tier owns final model/thinking resolution. Keep both
2533
+ // fields unset here so runAgent is the only resolver and ordinary Agent
2534
+ // calls cannot accidentally bypass the profile with a parent/default pin.
2535
+ let model = resolvedConfig.agentTierSelected
2536
+ ? undefined
2537
+ : resolveConfiguredDefaultModel(ctx.modelRegistry) ?? ctx.model;
2538
+ if (!resolvedConfig.agentTierSelected && resolvedConfig.modelInput) {
2566
2539
  const resolved = resolveModel(
2567
2540
  resolvedConfig.modelInput,
2568
2541
  ctx.modelRegistry,
@@ -2575,21 +2548,22 @@ Terse command-style prompts produce shallow, generic work.
2575
2548
  }
2576
2549
  }
2577
2550
 
2578
- // Scope validation: the effective resolved model is checked against the
2579
- // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
2580
- // in model-scope.ts so the nested delegation tools apply the same rule.
2581
- const scopeVerdict = checkModelScope({
2582
- model,
2583
- cwd: ctx.cwd,
2584
- modelRegistry: ctx.modelRegistry,
2585
- callerSupplied: resolvedConfig.modelFromParams,
2586
- agentLabel: customConfig?.displayName ?? subagentType,
2587
- modelInput: resolvedConfig.modelInput,
2588
- });
2589
- if (scopeVerdict.kind === "error")
2590
- return textResult(scopeVerdict.message);
2591
- if (scopeVerdict.kind === "warn")
2592
- ctx.ui.notify(scopeVerdict.message, "warning");
2551
+ // Tiered model scope is checked after the single final resolution in
2552
+ // runAgent. Only the legacy no-tier path is checked here.
2553
+ if (!resolvedConfig.agentTierSelected) {
2554
+ const scopeVerdict = checkModelScope({
2555
+ model,
2556
+ cwd: ctx.cwd,
2557
+ modelRegistry: ctx.modelRegistry,
2558
+ callerSupplied: resolvedConfig.modelFromParams,
2559
+ agentLabel: customConfig?.displayName ?? subagentType,
2560
+ modelInput: resolvedConfig.modelInput,
2561
+ });
2562
+ if (scopeVerdict.kind === "error")
2563
+ return textResult(scopeVerdict.message);
2564
+ if (scopeVerdict.kind === "warn")
2565
+ ctx.ui.notify(scopeVerdict.message, "warning");
2566
+ }
2593
2567
 
2594
2568
  const thinking = resolvedConfig.thinking;
2595
2569
  const inheritContext = resolvedConfig.inheritContext;
@@ -2616,19 +2590,20 @@ Terse command-style prompts produce shallow, generic work.
2616
2590
  writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
2617
2591
  };
2618
2592
 
2593
+ // Untiered spawns only. A tier resolves its model inside runAgent, and
2594
+ // its resolution callback supplies the label from there — computing one
2595
+ // here would name a model this path never resolved.
2619
2596
  const parentModelId = ctx.model?.id;
2620
- const effectiveModelId = model?.id;
2621
2597
  const modelName =
2622
- effectiveModelId && effectiveModelId !== parentModelId
2623
- ? (model?.name ?? effectiveModelId)
2624
- .replace(/^Claude\s+/i, "")
2625
- .toLowerCase()
2626
- : undefined;
2598
+ model && model.id !== parentModelId ? shortModelLabel(model) : undefined;
2627
2599
  const effectiveMaxTurns = normalizeMaxTurns(
2628
2600
  resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
2629
2601
  );
2630
2602
  const agentInvocation: AgentInvocation = {
2631
2603
  modelName,
2604
+ ...((resolvedConfig.requestedAgentTier ?? customConfig?.agentTier) === undefined
2605
+ ? {}
2606
+ : { agentTier: resolvedConfig.requestedAgentTier ?? customConfig?.agentTier }),
2632
2607
  thinking,
2633
2608
  // Explicit value only — the default fallback would just add noise.
2634
2609
  // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
@@ -2688,10 +2663,16 @@ Terse command-style prompts produce shallow, generic work.
2688
2663
  // at fire time, and the original is what a user edits.
2689
2664
  subagent_type: requestedType,
2690
2665
  prompt: params.prompt as string,
2666
+ // Only an explicitly requested tier is frozen into the job. An
2667
+ // agent's frontmatter tier is deliberately not copied: it is read
2668
+ // again at fire time, so editing the agent file still takes
2669
+ // effect, and it keeps its "frontmatter" source rather than being
2670
+ // replayed as a caller choice.
2671
+ tier: resolvedConfig.requestedAgentTier,
2691
2672
  // Store the resolved policy input (agent config first, tool params second)
2692
2673
  // so scheduled fires use the same model fallback as an immediate spawn.
2693
- model: resolvedConfig.modelInput,
2694
- thinking: thinking,
2674
+ model: resolvedConfig.agentTierSelected ? undefined : resolvedConfig.modelInput,
2675
+ thinking: resolvedConfig.agentTierSelected ? undefined : thinking,
2695
2676
  max_turns: effectiveMaxTurns,
2696
2677
  isolated: isolated,
2697
2678
  isolation: isolation,
@@ -2844,11 +2825,11 @@ Terse command-style prompts produce shallow, generic work.
2844
2825
  // call as failed instead of returning a successful-looking text result.
2845
2826
  id = manager.spawn(pi, ctx, subagentType, params.prompt, {
2846
2827
  description: params.description,
2847
- model,
2828
+ model: resolvedConfig.agentTierSelected ? undefined : model,
2848
2829
  maxTurns: effectiveMaxTurns,
2849
2830
  isolated,
2850
2831
  inheritContext,
2851
- thinkingLevel: thinking,
2832
+ thinkingLevel: resolvedConfig.agentTierSelected ? undefined : thinking,
2852
2833
  agentTier: resolvedConfig.requestedAgentTier,
2853
2834
  isBackground: true,
2854
2835
  isolation,
@@ -2994,11 +2975,11 @@ Terse command-style prompts produce shallow, generic work.
2994
2975
  params.prompt,
2995
2976
  {
2996
2977
  description: params.description,
2997
- model,
2978
+ model: resolvedConfig.agentTierSelected ? undefined : model,
2998
2979
  maxTurns: effectiveMaxTurns,
2999
2980
  isolated,
3000
2981
  inheritContext,
3001
- thinkingLevel: thinking,
2982
+ thinkingLevel: resolvedConfig.agentTierSelected ? undefined : thinking,
3002
2983
  agentTier: resolvedConfig.requestedAgentTier,
3003
2984
  isolation,
3004
2985
  invocation: agentInvocation,
@@ -4396,19 +4377,13 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4396
4377
  worktreeIsolation: isWorktreeIsolationEnabled(),
4397
4378
  maxSubagentDepth: getMaxSubagentDepth(),
4398
4379
  maxSubagentSpawnsPerBranch: manager.getMaxSubagentSpawnsPerBranch(),
4399
- ...(Object.keys(getWorkflowSettings().tiers ?? {}).length > 0 ||
4400
- getWorkflowSettings().defaultTier !== undefined ||
4401
- getWorkflowSettings().blockedDefaultTier === true ||
4402
- (getWorkflowSettings().blockedTiers?.length ?? 0) > 0
4403
- ? { workflow: getWorkflowSettings() }
4404
- : {}),
4405
- // Same shape as the workflow block above: written back only when the user
4406
- // actually configured tiers, so the snapshot never materializes an empty
4407
- // catalogue — or the shipped `fast` fallback — into the project settings
4408
- // file.
4380
+ // Written back only when the user actually configured tiers, so the
4381
+ // snapshot never materializes an empty catalogue — or the shipped
4382
+ // profiles and default — into the project settings file.
4409
4383
  ...(Object.keys(getAgentTiersConfiguredSettings().profiles ?? {}).length >
4410
4384
  0 ||
4411
4385
  getAgentTiersConfiguredSettings().defaultTier !== undefined ||
4386
+ getAgentTiersConfiguredSettings().noDefaultTier === true ||
4412
4387
  getAgentTiersConfiguredSettings().blockedDefaultTier === true ||
4413
4388
  (getAgentTiersConfiguredSettings().blockedProfiles?.length ?? 0) > 0
4414
4389
  ? { agentTiers: getAgentTiersConfiguredSettings() }
@@ -4448,8 +4423,19 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4448
4423
  * the numeric fields hand off to a text prompt.
4449
4424
  */
4450
4425
  const PICKER_IDS = new Set(["defaultModel"]);
4451
- /** Row value standing in for "no default tier configured". Not a tier key — keys reject whitespace, not words. */
4452
- const NO_DEFAULT_TIER = "none";
4426
+ /**
4427
+ * Row values for the two "no default tier" states, which behave differently:
4428
+ * leaving the field unset still lets a managed workflow call reach the shipped
4429
+ * fallback, while "none" withdraws that too. Both contain a space, which a
4430
+ * tier key may not, so a real tier can never shadow either one.
4431
+ *
4432
+ * Neither names the fallback tier. These strings are the sentinels the setter
4433
+ * below matches on, so they have to stay literal and stable — and *which*
4434
+ * tier `unset` reaches depends on the catalogue, which can remove it. The
4435
+ * row description says what it actually resolves to right now.
4436
+ */
4437
+ const UNSET_DEFAULT_TIER = "unset (workflows use the shipped fallback)";
4438
+ const NO_DEFAULT_TIER = "none (workflows fail closed)";
4453
4439
  /** Menu entry that starts a new tier instead of editing an existing one. */
4454
4440
  const NEW_TIER_ENTRY = "+ New tier...";
4455
4441
 
@@ -4790,12 +4776,25 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4790
4776
  {
4791
4777
  id: "defaultTier",
4792
4778
  label: "Default tier",
4793
- description:
4794
- tierKeys.length > 0
4795
- ? "Tier applied when neither the caller nor the agent names one. Edit the tiers themselves in /agents → Model tiers."
4796
- : "No tiers defined yet — create one in /agents → Model tiers.",
4797
- currentValue: getAgentTiersSettings().defaultTier ?? NO_DEFAULT_TIER,
4798
- values: [NO_DEFAULT_TIER, ...tierKeys],
4779
+ description: (() => {
4780
+ if (tierKeys.length === 0) return "No tiers defined yet — create one in /agents → Model tiers.";
4781
+ const fallback = shippedFallbackAgentTier();
4782
+ // Named from the resolver, not from a constant: on a catalogue that
4783
+ // removed the shipped profile, "unset" reaches nothing and behaves
4784
+ // exactly like "none", and the menu has to say so rather than
4785
+ // promise a tier that is gone.
4786
+ const unset =
4787
+ fallback === undefined
4788
+ ? '"unset" reaches no fallback here (the shipped profile has been removed), so managed workflow calls fail closed either way'
4789
+ : `"unset" leaves managed workflow calls on the shipped "${fallback}"`;
4790
+ return `Tier applied when neither the caller nor the agent names one. ${unset}; "none" withdraws that fallback so they fail closed. Edit the tiers themselves in /agents → Model tiers.`;
4791
+ })(),
4792
+ currentValue: (() => {
4793
+ const selection = getDefaultAgentTierSelection();
4794
+ if (selection.kind === "tier") return selection.tier;
4795
+ return selection.kind === "none" ? NO_DEFAULT_TIER : UNSET_DEFAULT_TIER;
4796
+ })(),
4797
+ values: [UNSET_DEFAULT_TIER, NO_DEFAULT_TIER, ...tierKeys],
4799
4798
  },
4800
4799
  {
4801
4800
  id: "joinMode",
@@ -4959,15 +4958,26 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4959
4958
  );
4960
4959
  }
4961
4960
  } else if (id === "defaultTier") {
4962
- const tier = value === NO_DEFAULT_TIER ? undefined : value;
4961
+ const selection: DefaultAgentTierSelection =
4962
+ value === UNSET_DEFAULT_TIER
4963
+ ? { kind: "unset" }
4964
+ : value === NO_DEFAULT_TIER
4965
+ ? { kind: "none" }
4966
+ : { kind: "tier", tier: value };
4963
4967
  setAgentTiersSettings(
4964
- setDefaultAgentTier(getAgentTiersSettings(), tier),
4968
+ setDefaultAgentTier(getAgentTiersSettings(), selection),
4965
4969
  );
4966
4970
  notifyApplied(
4967
4971
  ctx,
4968
- tier === undefined
4969
- ? "Default tier cleared. Spawns that name no tier use the default model."
4970
- : `Default tier set to ${tier}. The tool description updates on the next pi session.`,
4972
+ selection.kind === "tier"
4973
+ ? `Default tier set to ${selection.tier}. The tool description updates on the next pi session.`
4974
+ : selection.kind === "none"
4975
+ ? "Default tier set to none. Spawns that name no tier use the agent's own tier or the default model, and managed workflow calls now fail closed rather than falling back to the shipped tier."
4976
+ : `Default tier unset. Spawns that name no tier use the agent's own tier or the default model, and managed workflow calls ${
4977
+ shippedFallbackAgentTier() === undefined
4978
+ ? "fail closed, because this catalogue no longer defines the shipped fallback profile"
4979
+ : `fall back to the shipped "${shippedFallbackAgentTier()}"`
4980
+ }.`,
4971
4981
  );
4972
4982
  } else if (id === "joinMode") {
4973
4983
  setDefaultJoinMode(value as JoinMode);
@@ -4,6 +4,9 @@
4
4
  * the symbol-keyed override.
5
5
  */
6
6
 
7
+ import type { Api, Model } from "@earendil-works/pi-ai";
8
+ import type { ThinkingLevel } from "./types.js";
9
+
7
10
  /**
8
11
  * Immutable policy used while asking a model to draft an agent definition.
9
12
  * The parent owns all file writes, so the child receives no tools, extensions,
@@ -25,6 +28,17 @@ export interface InternalAgentConfigOverride {
25
28
  /** Symbol-keyed so ordinary JSON/RPC/public spawn options cannot carry it. */
26
29
  export const INTERNAL_AGENT_CONFIG_OVERRIDE = Symbol("pi-subagents.internal-agent-config-override");
27
30
 
31
+ /**
32
+ * Parent model/thinking chosen when a managed spawn is allocated. The symbol key
33
+ * keeps this capability out of JSON, the public spawn contract, and wire RPC.
34
+ */
35
+ export interface ParentPolicySnapshot {
36
+ readonly model?: Model<Api>;
37
+ readonly thinking?: ThinkingLevel;
38
+ }
39
+
40
+ export const INTERNAL_PARENT_POLICY_SNAPSHOT = Symbol("pi-subagents.internal-parent-policy-snapshot");
41
+
28
42
  /** The only no-tool policy currently issued by the package. */
29
43
  export const AGENT_DEFINITION_GENERATION_OVERRIDE: InternalAgentConfigOverride = Object.freeze({
30
44
  builtinToolNames: Object.freeze([] as string[]),
@@ -1,4 +1,5 @@
1
- import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
1
+ import { agentTierApplies } from "./agent-tiers.js";
2
+ import type { AgentTiersSettings } from "./settings.js";
2
3
  import type {
3
4
  AgentConfig,
4
5
  IsolationMode,
@@ -13,11 +14,10 @@ interface AgentInvocationParams {
13
14
  * are reachable only from programmatic callers and the legacy RPC.
14
15
  */
15
16
  tier?: string;
16
- /**
17
- * Workflow protocol tier (`small | medium | large`), set only by the managed
18
- * spawn path. Kept apart from `tier` so the protocol's union stays closed.
19
- */
20
- workflowTier?: WorkflowTier;
17
+ /** Effective Agent-tier settings, used only to avoid pre-resolving legacy fields. */
18
+ agentTiers?: AgentTiersSettings;
19
+ /** Set by callers that cannot inherit the parent model; see `ResolveAgentTierInput`. */
20
+ requireTier?: boolean;
21
21
  model?: string;
22
22
  thinking?: string;
23
23
  max_turns?: number;
@@ -35,7 +35,8 @@ export function resolveAgentInvocationConfig(
35
35
  modelFromParams: boolean;
36
36
  /** Passed to `resolveAgentTier` as the requested tier; precedence lives there. */
37
37
  requestedAgentTier?: string;
38
- workflowTier?: WorkflowTier;
38
+ /** True when a tier owns final model and thinking resolution. */
39
+ agentTierSelected: boolean;
39
40
  thinking?: ThinkingLevel;
40
41
  maxTurns?: number;
41
42
  inheritContext: boolean;
@@ -43,14 +44,24 @@ export function resolveAgentInvocationConfig(
43
44
  isolated: boolean;
44
45
  isolation?: IsolationMode;
45
46
  } {
47
+ // Asked, not restated: `agentTierApplies` runs the resolver's own selection.
48
+ // Whenever a tier applies it owns model and thinking outright, so pre-resolving
49
+ // a legacy field here would hand the runner a value the tier is about to
50
+ // discard, and a scope check against a model that never runs.
51
+ const agentTierSelected = agentTierApplies({
52
+ requestedTier: params.tier,
53
+ requireTier: params.requireTier,
54
+ agentConfig,
55
+ settings: params.agentTiers,
56
+ });
46
57
  return {
47
- modelInput: agentConfig?.model ?? params.model,
58
+ modelInput: agentTierSelected ? undefined : agentConfig?.model ?? params.model,
48
59
  requestedAgentTier: params.tier,
49
- workflowTier: params.workflowTier,
50
- modelFromParams: agentConfig?.model == null && params.model != null,
51
- thinking: (agentConfig?.thinking ?? params.thinking) as
52
- | ThinkingLevel
53
- | undefined,
60
+ agentTierSelected,
61
+ modelFromParams: !agentTierSelected && agentConfig?.model == null && params.model != null,
62
+ thinking: agentTierSelected
63
+ ? undefined
64
+ : (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
54
65
  maxTurns: agentConfig?.maxTurns ?? params.max_turns,
55
66
  inheritContext:
56
67
  agentConfig?.inheritContext ?? params.inherit_context ?? false,
@@ -98,3 +98,15 @@ export function resolveModel<TModel extends ModelEntry>(
98
98
  .join("\n");
99
99
  return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
100
100
  }
101
+
102
+ /**
103
+ * The short model label the agent UI shows, e.g. `haiku 4.5`.
104
+ *
105
+ * Lives here rather than at either call site because two paths now produce it:
106
+ * the Agent tool, from the model it pre-resolves for an untiered spawn, and the
107
+ * runner, from the model a tier's profile pinned. The label must read the same
108
+ * either way, so it is derived once.
109
+ */
110
+ export function shortModelLabel(model: Pick<ModelEntry, "id" | "name">): string {
111
+ return (model.name || model.id).replace(/^Claude\s+/iu, "").toLowerCase();
112
+ }
@@ -55,14 +55,17 @@ export function checkModelScope(args: {
55
55
  const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd);
56
56
  if (!allowed || isModelInScope(model, allowed)) return { kind: "ok" };
57
57
 
58
+ // A tier whose profile says `inherit` names no model of its own, so fall back
59
+ // to what it actually resolved to. Without this the refusal reads
60
+ // `Model not in scope: "undefined"`, which tells the caller nothing it can act on.
61
+ const modelLabel = modelInput ?? `${model.provider}/${model.id}`;
58
62
  if (callerSupplied) {
59
63
  const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
60
64
  return {
61
65
  kind: "error",
62
- message: `Model not in scope: "${modelInput}".\n\nAllowed models (from enabledModels):\n${list}`,
66
+ message: `Model not in scope: "${modelLabel}".\n\nAllowed models (from enabledModels):\n${list}`,
63
67
  };
64
68
  }
65
- const modelLabel = modelInput ?? `${model.provider}/${model.id}`;
66
69
  return {
67
70
  kind: "warn",
68
71
  message: `Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,