@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.
@@ -1,29 +1,22 @@
1
1
  /**
2
- * agent-tiers.ts — user-named model tiers for ordinary subagent spawns.
2
+ * agent-tiers.ts — user-named model tiers. The one tier catalogue.
3
3
  *
4
4
  * A tier is one name for a (model, thinking) pair. The host agent picks a tier
5
5
  * key and nothing else: the LLM-facing `Agent` tool exposes `tier` and does not
6
6
  * expose `model` or `thinking`, so the choice of which model runs stays with
7
7
  * whoever writes `subagents.json` rather than with the model deciding per call.
8
8
  *
9
- * Deliberately separate from `workflow-tiers.ts`. Workflow tiers are the fixed
10
- * `small | medium | large` vocabulary of the `pi-workflows` protocol and are
11
- * typed as that union; agent tiers are arbitrary user-chosen names. Sharing one
12
- * field between them would force `WorkflowTier` open to `string` and take the
13
- * protocol's exhaustiveness with it, so the two keep separate settings, separate
14
- * snapshot types, and separate fields on `AgentInvocation`. The mechanics they
15
- * both use — resolving a model reference, clamping thinking — are short enough
16
- * to state twice; `workflow-tiers.ts` keeps its own copy so this change cannot
17
- * alter the protocol-facing resolver's behavior.
18
- *
19
- * The two also disagree on precedence, which is why they are not one function:
20
- * a workflow tier only fills what agent frontmatter left blank, while an agent
21
- * tier overrides frontmatter's legacy `model:`/`thinking:`. That is the point of
22
- * agent tiers — the tier is the policy, and a per-agent pin is the older, weaker
23
- * statement of the same thing.
9
+ * Managed `pi-workflows` calls name a key from this same catalogue. There is no
10
+ * separate workflow-tier vocabulary and no mapping layer: a workflow that wants
11
+ * cheap work asks for the tier the user defined for cheap work, and gets the
12
+ * same resolver, the same precedence, and the same fail-closed errors an
13
+ * ordinary spawn gets. `getRoutingPolicySnapshot()` publishes the part of this
14
+ * catalogue a managed peer needs to reason about replay.
24
15
  */
25
16
 
26
17
  import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
18
+ import type { ManagedRoutingPolicy, ManagedRoutingPolicySnapshot } from "@signalridge/pi-subagents-protocol";
19
+ import { isManagedAgentTier, MAX_AGENT_TIER_KEY_LENGTH, routingPolicyFingerprint } from "@signalridge/pi-subagents-protocol";
27
20
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
28
21
  import { type AgentTierProfile, type AgentTiersSettings, TIER_THINKING_LEVELS, type TierThinking } from "./settings.js";
29
22
  import type { AgentConfig, ThinkingLevel } from "./types.js";
@@ -33,33 +26,99 @@ function effectiveModelId(model: Model<Api> | undefined): string | undefined {
33
26
  return model ? `${model.provider}/${model.id}` : undefined;
34
27
  }
35
28
 
36
- /** Longest accepted tier key. Long enough for any real name, short enough to render. */
37
- export const MAX_AGENT_TIER_KEY_LENGTH = 64;
38
-
39
29
  /**
40
- * The tier every fresh install gets: a cheap, low-thinking tier named `fast`.
30
+ * The tier-key bound and predicate, re-exported from the protocol package.
41
31
  *
42
- * Explore — the highest-frequency built-in spawn — points its `tier:` at it, so
43
- * read-only search does not silently inherit the parent session's most
44
- * expensive model on a machine that never configured `agentTiers`. This is the
45
- * tier strategy, not a per-agent vendor pin: the shipped profile is
46
- * provider-neutral (`inherit` model, low thinking), and any user who defines
47
- * `fast` in `subagents.json` replaces it wholesale.
48
- *
49
- * It is not shown as an available tier until settings are loaded; the merge in
50
- * `setAgentTiersSettings` is where a worktree that explicitly disables/renames
51
- * `fast` can win.
32
+ * They are defined once, on the wire, because that is the narrower of the two
33
+ * gates: a key this package accepted but the protocol rejected could never be
34
+ * sent to a managed peer. Everything in pi-subagents imports them from here so
35
+ * there is still one import site inside the package.
52
36
  */
53
- const SHIPPED_FAST_PROFILE: AgentTierProfile = {
37
+ export { isManagedAgentTier as isValidAgentTierKey, MAX_AGENT_TIER_KEY_LENGTH };
38
+
39
+ /**
40
+ * Fresh installs receive an effort ladder: `low`, `medium`, `high`. Every
41
+ * shipped profile inherits its model, so a new machine gets a usable vocabulary
42
+ * without this package ever pinning a vendor. A user definition in
43
+ * `subagents.json` replaces a shipped profile wholesale; a blocked profile is
44
+ * never resurrected.
45
+ */
46
+ const SHIPPED_LOW_PROFILE: AgentTierProfile = {
54
47
  model: "inherit",
55
48
  thinking: "low",
56
- description: "Fast, low-cost tier for cheap read-only work (shipped default)",
49
+ description: "Cheap, shallow work (shipped)",
50
+ };
51
+ const SHIPPED_MEDIUM_PROFILE: AgentTierProfile = {
52
+ model: "inherit",
53
+ thinking: "medium",
54
+ description: "Ordinary work (shipped)",
55
+ };
56
+ const SHIPPED_HIGH_PROFILE: AgentTierProfile = {
57
+ model: "inherit",
58
+ thinking: "high",
59
+ description: "Deep or risky work (shipped)",
57
60
  };
58
61
 
59
62
  export const SHIPPED_AGENT_TIER_PROFILES: Readonly<Record<string, AgentTierProfile>> = {
60
- fast: SHIPPED_FAST_PROFILE,
63
+ low: SHIPPED_LOW_PROFILE,
64
+ medium: SHIPPED_MEDIUM_PROFILE,
65
+ high: SHIPPED_HIGH_PROFILE,
61
66
  };
62
67
 
68
+ /**
69
+ * The tier a *managed* call gets when nobody named one.
70
+ *
71
+ * Deliberately not a global `defaultTier`. A managed workflow call fails closed
72
+ * without a tier, and "install the package, run a workflow, get a hard error"
73
+ * is not a defensible first experience — but that is the only case that needs a
74
+ * shipped answer. Making it the catalogue's default instead would take over
75
+ * every ordinary spawn as well, which would silence `defaultModel` and pin a
76
+ * thinking level on machines that asked for neither.
77
+ *
78
+ * `medium` inherits its model, so this commits to an effort level, not to a
79
+ * vendor — and, being `inherit`, it lands on the parent session's model. That
80
+ * is the honest trade: this fallback exists so a managed call has a *named*
81
+ * policy with a durable snapshot on a machine that configured none, not so it
82
+ * runs somewhere cheaper. A workspace that wants cheaper managed work names a
83
+ * `defaultTier` whose profile pins a model.
84
+ *
85
+ * It applies only while the user has expressed no opinion: any configured
86
+ * `defaultTier` wins, `noDefaultTier` suppresses it outright, a tombstoned
87
+ * default still blocks, and deleting the `medium` profile removes this fallback
88
+ * with it.
89
+ */
90
+ export const SHIPPED_DEFAULT_AGENT_TIER = "medium";
91
+
92
+ /**
93
+ * The default a managed call resolves against, or `undefined` when it must fail
94
+ * closed. The single definition of the shipped fallback: `selectAgentTier` consults
95
+ * it for a `requireTier` spawn and `getRoutingPolicySnapshot` publishes it, so a
96
+ * managed peer's replay identity can never disagree with what the host will do.
97
+ */
98
+ export function managedDefaultAgentTier(settings: AgentTiersSettings): string | undefined {
99
+ if (settings.blockedDefaultTier === true) return undefined;
100
+ if (settings.defaultTier !== undefined) return settings.defaultTier;
101
+ if (settings.noDefaultTier === true) return undefined;
102
+ return settings.profiles?.[SHIPPED_DEFAULT_AGENT_TIER] ? SHIPPED_DEFAULT_AGENT_TIER : undefined;
103
+ }
104
+
105
+ /**
106
+ * What leaving `defaultTier` unset would actually give a managed call, whatever
107
+ * the user has chosen right now.
108
+ *
109
+ * The Settings menu offers `unset` as a distinct choice from `none`, and the
110
+ * difference between them is exactly this value — which is `undefined` on a
111
+ * catalogue whose `medium` profile has been edited away or tombstoned. A menu
112
+ * that says "unset uses the shipped medium" there would be describing a
113
+ * fallback that no longer exists, and `unset` would behave identically to
114
+ * `none` while claiming otherwise. Asked of the same function that decides it,
115
+ * with the current choice stripped, so the answer cannot drift from the
116
+ * behavior.
117
+ */
118
+ export function shippedFallbackAgentTier(settings: AgentTiersSettings = agentTiersSettings): string | undefined {
119
+ return managedDefaultAgentTier({ ...settings, defaultTier: undefined, noDefaultTier: false });
120
+ }
121
+
63
122
  let agentTiersSettings: AgentTiersSettings = {}; // effective view (shipped tiers merged)
64
123
  let agentTiersConfigured: AgentTiersSettings = {}; // exactly what the user configured
65
124
 
@@ -81,12 +140,12 @@ function sameProfile(a: AgentTierProfile, b: AgentTierProfile): boolean {
81
140
  /**
82
141
  * Install the effective tier catalogue.
83
142
  *
84
- * The shipped `fast` tier is merged in unless the caller already defined it or
85
- * explicitly blocked it — a user catalogue wins over the shipped default, and a
86
- * tombstone means "do not substitute", which applies to shipped defaults too.
143
+ * Each shipped tier is merged in unless the caller already defined it or
144
+ * explicitly blocked it — a user catalogue wins over a shipped profile, and a
145
+ * tombstone means "do not substitute", which applies to shipped profiles too.
87
146
  *
88
147
  * The configured view is derived from the same input by stripping profiles that
89
- * exactly equal a shipped default, so the UI can operate on the effective view
148
+ * exactly equal a shipped profile, so the UI can operate on the effective view
90
149
  * and send it back without materializing untouched shipped tiers into
91
150
  * `subagents.json`. Editing a shipped tier (changing its model, thinking, or
92
151
  * description) makes it a user-owned profile and it is then persisted; deleting
@@ -107,6 +166,11 @@ export function setAgentTiersSettings(settings: AgentTiersSettings): void {
107
166
  if (!blocked.has(key) && profiles[key] === undefined) profiles[key] = profile;
108
167
  }
109
168
 
169
+ // `defaultTier` is passed through untouched. The shipped fallback is not
170
+ // merged in here: it is scoped to managed calls (see
171
+ // `managedDefaultAgentTier`), so the effective catalogue keeps saying "the
172
+ // user named no default" and an ordinary spawn still falls through to
173
+ // `defaultModel` and the parent session.
110
174
  agentTiersSettings = { ...effective, profiles };
111
175
  const configured: AgentTiersSettings = { ...effective };
112
176
  if (Object.keys(configuredProfiles).length > 0) configured.profiles = configuredProfiles;
@@ -115,22 +179,12 @@ export function setAgentTiersSettings(settings: AgentTiersSettings): void {
115
179
  }
116
180
 
117
181
  /**
118
- * A key is usable when it is a bounded, non-blank, whitespace-free string.
182
+ * Where the tier that was used came from; recorded for audit.
119
183
  *
120
- * Whitespace is excluded because the key appears in tool descriptions and error
121
- * messages as a bare token; a key with a space in it reads as two keys.
184
+ * A managed workflow call is a `call`: an orchestrator naming a tier per
185
+ * dispatch is the same act whether the orchestrator is the host model or a
186
+ * workflow script, so it gets the same precedence and the same scope policy.
122
187
  */
123
- export function isValidAgentTierKey(value: unknown): value is string {
124
- return (
125
- typeof value === "string" &&
126
- value.length > 0 &&
127
- value.length <= MAX_AGENT_TIER_KEY_LENGTH &&
128
- value.trim() === value &&
129
- !/\s/u.test(value)
130
- );
131
- }
132
-
133
- /** Where the tier that was used came from; recorded for audit. */
134
188
  export type AgentTierSource = "call" | "frontmatter" | "default";
135
189
 
136
190
  /** Durable, JSON-safe record of how one spawn's model and thinking were chosen. */
@@ -162,6 +216,12 @@ export interface AgentTierResolution {
162
216
  export interface ResolveAgentTierInput {
163
217
  /** Tier key from the spawn call. Highest precedence. */
164
218
  requestedTier?: string;
219
+ /**
220
+ * This spawn cannot fall back to the parent session's model, so the shipped
221
+ * fallback applies when nothing else named a tier. Managed workflow calls set
222
+ * it; an ordinary spawn leaves it off and simply resolves no tier.
223
+ */
224
+ requireTier?: boolean;
165
225
  /** The agent's own config; supplies its default tier and legacy model/thinking. */
166
226
  agentConfig?: AgentConfig;
167
227
  /** Overrides the module-level settings; tests and callers with their own load. */
@@ -171,6 +231,12 @@ export interface ResolveAgentTierInput {
171
231
  modelRegistry: ModelRegistry<Model<Api>>;
172
232
  }
173
233
 
234
+ /** The part of a resolve request that decides *which* tier applies. */
235
+ export type TierSelectionInput = Pick<
236
+ ResolveAgentTierInput,
237
+ "requestedTier" | "requireTier" | "agentConfig" | "settings"
238
+ >;
239
+
174
240
  /** Thrown for every fail-closed tier condition so callers can report it verbatim. */
175
241
  export class AgentTierError extends Error {
176
242
  constructor(message: string) {
@@ -208,6 +274,7 @@ function withoutBlocked(blocked: string[] | undefined, key: string): string[] |
208
274
  function compactTierSettings(settings: AgentTiersSettings): AgentTiersSettings {
209
275
  const out: AgentTiersSettings = {};
210
276
  if (settings.defaultTier !== undefined) out.defaultTier = settings.defaultTier;
277
+ else if (settings.noDefaultTier) out.noDefaultTier = true;
211
278
  if (settings.profiles && Object.keys(settings.profiles).length > 0) out.profiles = settings.profiles;
212
279
  if (settings.blockedProfiles && settings.blockedProfiles.length > 0) {
213
280
  out.blockedProfiles = settings.blockedProfiles;
@@ -242,13 +309,11 @@ export function upsertAgentTierProfile(
242
309
  * turn every later spawn that names no tier into a hard refusal, which is a
243
310
  * strange thing to get from deleting a tier you had stopped using.
244
311
  *
245
- * Deleting a shipped tier (the default `fast`) tombstones it instead of just
246
- * dropping it: the shipped merge in `setAgentTiersSettings` would otherwise
247
- * silently re-add it on the next load, and a user who deletes it means it. The
248
- * tombstone says "do not substitute", which is exactly the semantics the load
249
- * path already honors for malformed profiles. Explore still names `fast` in its
250
- * frontmatter, so the spawn refusal then says so loudly until the agent file or
251
- * the tier is fixed.
312
+ * Deleting a shipped tier tombstones it instead of just dropping it: the
313
+ * shipped merge in `setAgentTiersSettings` would otherwise silently re-add it
314
+ * on the next load, and a user who deletes it means it. The tombstone says
315
+ * "do not substitute", which is exactly the semantics the load path already
316
+ * honors for malformed profiles.
252
317
  */
253
318
  export function removeAgentTierProfile(settings: AgentTiersSettings, key: string): AgentTiersSettings {
254
319
  const { [key]: _removed, ...profiles } = settings.profiles ?? {};
@@ -288,7 +353,30 @@ export function offerableTierThinking(
288
353
  }
289
354
 
290
355
  /**
291
- * Set or clear the default tier.
356
+ * What the user chose for the default tier.
357
+ *
358
+ * `none` and `unset` are different policies, and a single "no default" value
359
+ * cannot express both: an absent `defaultTier` still lets a managed workflow
360
+ * call reach the shipped fallback, while `none` is the statement that managed
361
+ * calls should fail closed as well. Typed rather than modelled as
362
+ * `string | undefined` so a caller has to say which one it means, and so a menu
363
+ * can render the difference instead of showing one word for two behaviors.
364
+ */
365
+ export type DefaultAgentTierSelection =
366
+ | { kind: "tier"; tier: string }
367
+ | { kind: "none" }
368
+ | { kind: "unset" };
369
+
370
+ /** Which of the three states the catalogue is currently in. */
371
+ export function getDefaultAgentTierSelection(
372
+ settings: AgentTiersSettings = agentTiersSettings,
373
+ ): DefaultAgentTierSelection {
374
+ if (settings.defaultTier !== undefined) return { kind: "tier", tier: settings.defaultTier };
375
+ return settings.noDefaultTier === true ? { kind: "none" } : { kind: "unset" };
376
+ }
377
+
378
+ /**
379
+ * Set, clear, or withdraw the default tier.
292
380
  *
293
381
  * Always clears `blockedDefaultTier`: that tombstone describes the malformed
294
382
  * value this call is replacing, and keeping it would make the resolver refuse
@@ -296,25 +384,46 @@ export function offerableTierThinking(
296
384
  */
297
385
  export function setDefaultAgentTier(
298
386
  settings: AgentTiersSettings,
299
- key: string | undefined,
387
+ selection: DefaultAgentTierSelection,
300
388
  ): AgentTiersSettings {
301
- return compactTierSettings({ ...settings, defaultTier: key, blockedDefaultTier: false });
389
+ return compactTierSettings({
390
+ ...settings,
391
+ defaultTier: selection.kind === "tier" ? selection.tier : undefined,
392
+ noDefaultTier: selection.kind === "none",
393
+ blockedDefaultTier: false,
394
+ });
302
395
  }
303
396
 
304
397
  /**
305
398
  * Which tier applies, and where it came from.
306
399
  *
307
400
  * An explicitly requested tier that does not exist is an error rather than a
308
- * fallback: the host asked for a specific policy, and quietly running a
401
+ * fallback: the caller asked for a specific policy, and quietly running a
309
402
  * different model than the one it selected is worse than refusing.
403
+ *
404
+ * Precedence is `call > frontmatter > default`. A tier named per dispatch is
405
+ * current policy; an agent's own `tier:` is the older, weaker statement of the
406
+ * same thing. This holds for a workflow script exactly as it does for the host
407
+ * model — both are orchestrators routing one call.
408
+ *
409
+ * A `requireTier` spawn gets one extra step after the configured default: the
410
+ * shipped fallback. It is last because it is the only step the user did not
411
+ * write, and it is reachable only from a caller that has no parent model to
412
+ * fall back to.
413
+ *
414
+ * Exported because the managed-spawn path needs the tier *key* before the
415
+ * runner resolves — a tombstone and the lifecycle events carry it as a label.
416
+ * That caller asks this function rather than rebuilding the same three-step
417
+ * fallback beside it, for the reason given on {@link agentTierApplies}: a
418
+ * second copy of a precedence is only ever a way for the two to disagree.
310
419
  */
311
- function selectTier(
312
- input: ResolveAgentTierInput,
313
- settings: AgentTiersSettings,
420
+ export function selectAgentTier(
421
+ input: TierSelectionInput,
422
+ settings: AgentTiersSettings = agentTiersSettings,
314
423
  ): { tier: string; source: AgentTierSource } | undefined {
315
424
  const requested = input.requestedTier;
316
425
  if (requested !== undefined) {
317
- if (!isValidAgentTierKey(requested)) {
426
+ if (!isManagedAgentTier(requested)) {
318
427
  throw new AgentTierError(
319
428
  `Invalid agent tier key. Keys are non-empty, contain no whitespace, and are at most ` +
320
429
  `${MAX_AGENT_TIER_KEY_LENGTH} characters. Available tiers: ${tierKeyList(settings)}`,
@@ -330,7 +439,29 @@ function selectTier(
330
439
  throw new AgentTierError("agentTiers.defaultTier is blocked by malformed configuration");
331
440
  }
332
441
  if (settings.defaultTier !== undefined) return { tier: settings.defaultTier, source: "default" };
333
- return undefined;
442
+ // Only a spawn that may not inherit the parent reaches the shipped fallback.
443
+ if (input.requireTier !== true) return undefined;
444
+ const fallback = managedDefaultAgentTier(settings);
445
+ return fallback === undefined ? undefined : { tier: fallback, source: "default" };
446
+ }
447
+
448
+ /**
449
+ * Whether `resolveAgentTier` will select a tier for this spawn.
450
+ *
451
+ * The callers that pre-compute a legacy model or thinking value need this
452
+ * before the runner resolves: a selected tier owns both outright, so
453
+ * pre-resolving one would hand the runner a value it is about to discard, and a
454
+ * scope check against a model that never runs. Implemented by asking
455
+ * `selectAgentTier` itself rather than restating its precedence, so the two cannot
456
+ * drift. A fail-closed condition counts as "applies": the tier path owns the
457
+ * refusal, and the legacy path must not quietly answer in its place.
458
+ */
459
+ export function agentTierApplies(input: TierSelectionInput): boolean {
460
+ try {
461
+ return selectAgentTier(input, input.settings ?? agentTiersSettings) !== undefined;
462
+ } catch {
463
+ return true;
464
+ }
334
465
  }
335
466
 
336
467
  function describeSource(source: AgentTierSource, agentName: string | undefined): string {
@@ -349,12 +480,14 @@ function describeSource(source: AgentTierSource, agentName: string | undefined):
349
480
  *
350
481
  * Returns `undefined` fields and no snapshot when no tier applies at all, which
351
482
  * is how a workspace that has configured none keeps its previous behavior: the
352
- * caller then falls back to the agent's legacy `model:`/`thinking:` frontmatter
353
- * and finally to the parent session.
483
+ * caller then falls back to a programmatic model option, the configured
484
+ * `defaultModel`, and finally the parent session. A `requireTier` spawn cannot
485
+ * take that path, so it reaches the shipped fallback instead — and fails closed
486
+ * when the user has suppressed that too.
354
487
  */
355
488
  export function resolveAgentTier(input: ResolveAgentTierInput): AgentTierResolution {
356
489
  const settings = input.settings ?? agentTiersSettings;
357
- const selected = selectTier(input, settings);
490
+ const selected = selectAgentTier(input, settings);
358
491
  if (!selected) return {};
359
492
 
360
493
  const { tier, source } = selected;
@@ -515,3 +648,36 @@ export function buildAgentTierParameterDescription(settings: AgentTiersSettings
515
648
  " A tier overrides the agent's default. Unknown tiers are rejected rather than substituted."
516
649
  );
517
650
  }
651
+
652
+ /**
653
+ * The catalogue as a managed peer needs to see it.
654
+ *
655
+ * Protocol-shaped on purpose: no settings-file details, no descriptions, no
656
+ * host paths — only what decides how a tier resolves, so a peer can tell
657
+ * whether a journaled call would still resolve the same way. `defaultTier` is
658
+ * the *managed* default, shipped fallback included: every consumer of this
659
+ * snapshot is a managed caller, and publishing the bare configured value would
660
+ * make its replay identity disagree with what the host actually selects.
661
+ *
662
+ * Both sorts use the default code-unit order rather than `localeCompare`.
663
+ * `blockedProfiles` is an array, so its order reaches the fingerprint, and a
664
+ * locale-dependent comparator would let the same catalogue hash differently on
665
+ * two machines — or on one machine after its locale changed. Object keys are
666
+ * canonicalized again by `canonicalizeRoutingPolicy`; sorting them here only
667
+ * keeps the wire payload readable.
668
+ */
669
+ export function getRoutingPolicySnapshot(
670
+ settings: AgentTiersSettings = agentTiersSettings,
671
+ ): ManagedRoutingPolicySnapshot {
672
+ const policy: ManagedRoutingPolicy = {
673
+ defaultTier: managedDefaultAgentTier(settings) ?? null,
674
+ profiles: Object.fromEntries(
675
+ Object.entries(settings.profiles ?? {})
676
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
677
+ .map(([key, profile]) => [key, { model: profile.model, thinking: profile.thinking }]),
678
+ ),
679
+ blockedProfiles: [...(settings.blockedProfiles ?? [])].sort(),
680
+ blockedDefaultTier: settings.blockedDefaultTier === true,
681
+ };
682
+ return { policy, fingerprint: routingPolicyFingerprint(policy) };
683
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import {
10
10
  CHILD_CONTEXT_CAPABILITY,
11
+ type ManagedRoutingPolicySnapshot,
11
12
  PROTOCOL_CAPABILITIES,
12
13
  PROTOCOL_VERSION,
13
14
  parseManagedSpawnRequest,
@@ -16,6 +17,7 @@ import {
16
17
  export { CHILD_CONTEXT_CAPABILITY, PROTOCOL_CAPABILITIES, PROTOCOL_VERSION };
17
18
 
18
19
  import type { ManagedSpawnPolicy, ManagedSpawnRequest, ManagedSpawnResult } from "./agent-manager.js";
20
+ import { getRoutingPolicySnapshot } from "./agent-tiers.js";
19
21
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
20
22
  import type { AgentOwner } from "./types.js";
21
23
 
@@ -49,6 +51,8 @@ export interface RpcDeps {
49
51
  pi: unknown;
50
52
  getCtx: () => unknown | undefined;
51
53
  manager: SpawnCapable;
54
+ /** Override for focused consumers/tests; production reads live policy state. */
55
+ getRoutingPolicy?: () => ManagedRoutingPolicySnapshot;
52
56
  }
53
57
 
54
58
  export interface RpcHandle {
@@ -164,10 +168,12 @@ function handleRpc(
164
168
  */
165
169
  export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
166
170
  const { events, pi, getCtx, manager } = deps;
171
+ const getRoutingPolicy = deps.getRoutingPolicy ?? getRoutingPolicySnapshot;
167
172
 
168
173
  const unsubPing = handleRpc(events, "subagents:rpc:ping", () => ({
169
174
  version: PROTOCOL_VERSION,
170
175
  capabilities: PROTOCOL_CAPABILITIES,
176
+ routingPolicy: getRoutingPolicy(),
171
177
  }));
172
178
 
173
179
  const unsubSpawn = handleRpc(events, "subagents:rpc:spawn", (params) => {
@@ -34,12 +34,12 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
34
34
  builtinToolNames: READ_ONLY_TOOLS,
35
35
  extensions: true,
36
36
  skills: true,
37
- // Runs on the shipped `fast` agent tier (model inherit, low thinking) so
37
+ // Runs on the shipped `low` agent tier (model inherit, low thinking) so
38
38
  // read-only search does not inherit the parent session's most expensive
39
39
  // model on machines that never configured agentTiers. The tier is the
40
- // policy: point `fast` at a cheap model in subagents.json and Explore
40
+ // policy: point `low` at a cheap model in subagents.json and Explore
41
41
  // follows without touching agent files.
42
- agentTier: "fast",
42
+ agentTier: "low",
43
43
  systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
44
44
  You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
45
45
  Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.