@signalridge/pi-subagents 1.2.0 → 1.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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * agent-tiers.ts — user-named model tiers for ordinary subagent spawns.
3
+ *
4
+ * A tier is one name for a (model, thinking) pair. The host agent picks a tier
5
+ * key and nothing else: the LLM-facing `Agent` tool exposes `tier` and does not
6
+ * expose `model` or `thinking`, so the choice of which model runs stays with
7
+ * whoever writes `subagents.json` rather than with the model deciding per call.
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.
24
+ */
25
+
26
+ import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
27
+ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
28
+ import type { AgentTierProfile, AgentTiersSettings, TierThinking } from "./settings.js";
29
+ import type { AgentConfig, ThinkingLevel } from "./types.js";
30
+
31
+ /** `provider/id`, or undefined when no model was selected. */
32
+ function effectiveModelId(model: Model<Api> | undefined): string | undefined {
33
+ return model ? `${model.provider}/${model.id}` : undefined;
34
+ }
35
+
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
+ let agentTiersSettings: AgentTiersSettings = {};
40
+
41
+ export function getAgentTiersSettings(): AgentTiersSettings {
42
+ return structuredClone(agentTiersSettings);
43
+ }
44
+
45
+ export function setAgentTiersSettings(settings: AgentTiersSettings): void {
46
+ agentTiersSettings = structuredClone(settings);
47
+ }
48
+
49
+ /**
50
+ * A key is usable when it is a bounded, non-blank, whitespace-free string.
51
+ *
52
+ * Whitespace is excluded because the key appears in tool descriptions and error
53
+ * messages as a bare token; a key with a space in it reads as two keys.
54
+ */
55
+ export function isValidAgentTierKey(value: unknown): value is string {
56
+ return (
57
+ typeof value === "string" &&
58
+ value.length > 0 &&
59
+ value.length <= MAX_AGENT_TIER_KEY_LENGTH &&
60
+ value.trim() === value &&
61
+ !/\s/u.test(value)
62
+ );
63
+ }
64
+
65
+ /** Where the tier that was used came from; recorded for audit. */
66
+ export type AgentTierSource = "call" | "frontmatter" | "default";
67
+
68
+ /** Durable, JSON-safe record of how one spawn's model and thinking were chosen. */
69
+ export interface AgentTierResolutionSnapshot {
70
+ /** The tier key that was applied. */
71
+ tier: string;
72
+ source: AgentTierSource;
73
+ /** Effective provider/model id after resolution. */
74
+ model?: string;
75
+ /** Effective thinking level; omitted when the model supports none. */
76
+ thinking?: ThinkingLevel;
77
+ /** The profile's model reference, before resolution. */
78
+ configuredModel: string;
79
+ /** The profile's thinking value, before clamping. */
80
+ configuredThinking: TierThinking;
81
+ /** Level asked for after `inherit` resolved against the parent. */
82
+ requestedThinking?: ThinkingLevel;
83
+ /** True when pi-ai lowered the requested level for this model. */
84
+ clamped?: boolean;
85
+ diagnostic?: string;
86
+ }
87
+
88
+ export interface AgentTierResolution {
89
+ model?: Model<Api>;
90
+ thinkingLevel?: ThinkingLevel;
91
+ snapshot?: AgentTierResolutionSnapshot;
92
+ }
93
+
94
+ export interface ResolveAgentTierInput {
95
+ /** Tier key from the spawn call. Highest precedence. */
96
+ requestedTier?: string;
97
+ /** The agent's own config; supplies its default tier and legacy model/thinking. */
98
+ agentConfig?: AgentConfig;
99
+ /** Overrides the module-level settings; tests and callers with their own load. */
100
+ settings?: AgentTiersSettings;
101
+ parentModel?: Model<Api>;
102
+ parentThinking?: ThinkingLevel;
103
+ modelRegistry: ModelRegistry<Model<Api>>;
104
+ }
105
+
106
+ /** Thrown for every fail-closed tier condition so callers can report it verbatim. */
107
+ export class AgentTierError extends Error {
108
+ constructor(message: string) {
109
+ super(message);
110
+ this.name = "AgentTierError";
111
+ }
112
+ }
113
+
114
+ function knownTierKeys(settings: AgentTiersSettings): string[] {
115
+ return Object.keys(settings.profiles ?? {}).sort((a, b) => a.localeCompare(b));
116
+ }
117
+
118
+ function tierKeyList(settings: AgentTiersSettings): string {
119
+ const keys = knownTierKeys(settings);
120
+ return keys.length > 0 ? keys.join(", ") : "(none configured)";
121
+ }
122
+
123
+ /**
124
+ * Which tier applies, and where it came from.
125
+ *
126
+ * An explicitly requested tier that does not exist is an error rather than a
127
+ * fallback: the host asked for a specific policy, and quietly running a
128
+ * different model than the one it selected is worse than refusing.
129
+ */
130
+ function selectTier(
131
+ input: ResolveAgentTierInput,
132
+ settings: AgentTiersSettings,
133
+ ): { tier: string; source: AgentTierSource } | undefined {
134
+ const requested = input.requestedTier;
135
+ if (requested !== undefined) {
136
+ if (!isValidAgentTierKey(requested)) {
137
+ throw new AgentTierError(
138
+ `Invalid agent tier key. Keys are non-empty, contain no whitespace, and are at most ` +
139
+ `${MAX_AGENT_TIER_KEY_LENGTH} characters. Available tiers: ${tierKeyList(settings)}`,
140
+ );
141
+ }
142
+ return { tier: requested, source: "call" };
143
+ }
144
+
145
+ const frontmatter = input.agentConfig?.agentTier;
146
+ if (frontmatter !== undefined) return { tier: frontmatter, source: "frontmatter" };
147
+
148
+ if (settings.blockedDefaultTier) {
149
+ throw new AgentTierError("agentTiers.defaultTier is blocked by malformed configuration");
150
+ }
151
+ if (settings.defaultTier !== undefined) return { tier: settings.defaultTier, source: "default" };
152
+ return undefined;
153
+ }
154
+
155
+ function describeSource(source: AgentTierSource, agentName: string | undefined): string {
156
+ switch (source) {
157
+ case "call":
158
+ return "requested by the caller";
159
+ case "frontmatter":
160
+ return `set by agent "${agentName ?? "unknown"}"`;
161
+ case "default":
162
+ return "the configured agentTiers.defaultTier";
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Resolve a spawn's model and thinking from its tier.
168
+ *
169
+ * Returns `undefined` fields and no snapshot when no tier applies at all, which
170
+ * is how a workspace that has configured none keeps its previous behavior: the
171
+ * caller then falls back to the agent's legacy `model:`/`thinking:` frontmatter
172
+ * and finally to the parent session.
173
+ */
174
+ export function resolveAgentTier(input: ResolveAgentTierInput): AgentTierResolution {
175
+ const settings = input.settings ?? agentTiersSettings;
176
+ const selected = selectTier(input, settings);
177
+ if (!selected) return {};
178
+
179
+ const { tier, source } = selected;
180
+ const origin = describeSource(source, input.agentConfig?.name);
181
+
182
+ if (settings.blockedProfiles?.includes(tier)) {
183
+ throw new AgentTierError(
184
+ `Agent tier "${tier}" (${origin}) is blocked by a malformed profile in subagents.json. ` +
185
+ `Fix or remove it; a tier is never silently replaced by another model.`,
186
+ );
187
+ }
188
+
189
+ const profile: AgentTierProfile | undefined = settings.profiles?.[tier];
190
+ if (!profile) {
191
+ throw new AgentTierError(
192
+ `Unknown agent tier "${tier}" (${origin}). Available tiers: ${tierKeyList(settings)}`,
193
+ );
194
+ }
195
+
196
+ // A tier owns its model outright, so an unresolvable reference fails the spawn
197
+ // instead of degrading into whatever the parent happens to be running — the
198
+ // caller asked for this policy by name.
199
+ let model = input.parentModel;
200
+ if (profile.model !== "inherit") {
201
+ const resolved = resolveModel(profile.model, input.modelRegistry);
202
+ if (typeof resolved === "string") {
203
+ throw new AgentTierError(`Agent tier "${tier}" (${origin}) has an unavailable model: ${resolved}`);
204
+ }
205
+ model = resolved;
206
+ }
207
+
208
+ const requestedThinking = profile.thinking === "inherit" ? input.parentThinking : profile.thinking;
209
+ let thinkingLevel = requestedThinking;
210
+ let clamped = false;
211
+ let diagnostic: string | undefined;
212
+ if (model && requestedThinking) {
213
+ const clampedLevel = clampThinkingLevel(model, requestedThinking);
214
+ if (clampedLevel !== requestedThinking) {
215
+ clamped = true;
216
+ diagnostic =
217
+ `Thinking level "${requestedThinking}" is not supported by ${effectiveModelId(model) ?? "the selected model"}; ` +
218
+ `using "${clampedLevel}" (supported: ${getSupportedThinkingLevels(model).join(", ")}).`;
219
+ }
220
+ // "off" is a ModelThinkingLevel sentinel, not a ThinkingLevel an AgentSession
221
+ // accepts. Omitting the option leaves the provider's own off behavior alone.
222
+ thinkingLevel = clampedLevel === "off" ? undefined : (clampedLevel as ThinkingLevel);
223
+ }
224
+
225
+ const snapshot: AgentTierResolutionSnapshot = {
226
+ tier,
227
+ source,
228
+ ...(effectiveModelId(model) ? { model: effectiveModelId(model) } : {}),
229
+ ...(thinkingLevel ? { thinking: thinkingLevel } : {}),
230
+ configuredModel: profile.model,
231
+ configuredThinking: profile.thinking,
232
+ ...(requestedThinking !== undefined ? { requestedThinking } : {}),
233
+ ...(clamped ? { clamped: true } : {}),
234
+ ...(diagnostic ? { diagnostic } : {}),
235
+ };
236
+
237
+ return { model, thinkingLevel, snapshot };
238
+ }
239
+
240
+ /**
241
+ * The tier catalogue, rendered for the `Agent` tool description.
242
+ *
243
+ * The host has to know the vocabulary before its first call, so this is injected
244
+ * into the tool description at registration rather than exposed through a lookup
245
+ * tool the model would have to remember to call. Only names, descriptions,
246
+ * models and thinking levels appear — nothing here reads credentials.
247
+ */
248
+ export function buildAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
249
+ const keys = knownTierKeys(settings);
250
+ if (keys.length === 0) return "";
251
+
252
+ const entries = keys.map((key) => {
253
+ const profile = settings.profiles?.[key];
254
+ if (!profile) return `- ${key}`;
255
+ // A profile without its own description is still worth listing; the key is
256
+ // the description in that case, which is what a terse config intends.
257
+ const summary = profile.description ?? key;
258
+ return `- ${key}: ${summary}\n model: ${profile.model}\n thinking: ${profile.thinking}`;
259
+ });
260
+
261
+ const defaultLine =
262
+ settings.defaultTier !== undefined ? `\n\nDefault tier: ${settings.defaultTier}` : "";
263
+ return `Available agent tiers:\n\n${entries.join("\n\n")}${defaultLine}\n\nThe caller may pass only a tier key. Do not pass model or thinking directly.`;
264
+ }
265
+
266
+ /** One line per tier, for the compact tool description. */
267
+ export function buildCompactAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
268
+ const keys = knownTierKeys(settings);
269
+ if (keys.length === 0) return "";
270
+
271
+ const entries = keys.map((key) => {
272
+ const profile = settings.profiles?.[key];
273
+ if (!profile) return `- ${key}`;
274
+ return `- ${key}: ${profile.description ?? key} (${profile.model}, thinking ${profile.thinking})`;
275
+ });
276
+ const defaultSuffix = settings.defaultTier !== undefined ? ` Default: ${settings.defaultTier}.` : "";
277
+ return `Agent tiers (pass \`tier\`, never model/thinking):\n${entries.join("\n")}${defaultSuffix}`;
278
+ }
279
+
280
+ /** The configured default tier key, or "" when none is set. */
281
+ export function getDefaultAgentTierText(settings: AgentTiersSettings = agentTiersSettings): string {
282
+ return settings.defaultTier ?? "";
283
+ }
284
+
285
+ /** Description for the `tier` parameter, naming the keys this workspace defines. */
286
+ export function buildAgentTierParameterDescription(settings: AgentTiersSettings = agentTiersSettings): string {
287
+ const keys = knownTierKeys(settings);
288
+ const available = keys.length > 0 ? keys.join(", ") : "none configured";
289
+ const fallback =
290
+ settings.defaultTier !== undefined
291
+ ? ` Omit to use the agent's own tier, or "${settings.defaultTier}".`
292
+ : " Omit to use the agent's own tier.";
293
+ return (
294
+ `Model tier for this spawn, chosen by name. Available: ${available}.${fallback}` +
295
+ " A tier overrides the agent's default. Unknown tiers are rejected rather than substituted."
296
+ );
297
+ }
@@ -5,6 +5,7 @@
5
5
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
6
6
  import { basename, join, resolve } from "node:path";
7
7
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
+ import { isValidAgentTierKey } from "./agent-tiers.js";
8
9
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
9
10
  import { DEFAULT_AGENTS } from "./default-agents.js";
10
11
  import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
@@ -174,6 +175,7 @@ function loadFromDir(
174
175
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
175
176
  excludeExtensions: csvListOptional(fm.exclude_extensions),
176
177
  skills: inheritField(fm.skills ?? fm.inherit_skills),
178
+ agentTier: parseTier(fm.tier, path, warn),
177
179
  model: str(fm.model),
178
180
  thinking: str(fm.thinking) as ThinkingLevel | undefined,
179
181
  maxTurns: nonNegativeInt(fm.max_turns),
@@ -266,6 +268,25 @@ function label(val: unknown): string | undefined {
266
268
  return raw === undefined ? undefined : sanitizeDisplayText(raw);
267
269
  }
268
270
 
271
+ /**
272
+ * Parse the agent's default model tier.
273
+ *
274
+ * Only the shape is checked here; whether the key names a configured profile is
275
+ * decided at spawn time, because the catalogue lives in `subagents.json` and may
276
+ * legitimately change after this file was read. A malformed key is dropped with
277
+ * a warning rather than failing the load: the rest of the agent is still usable,
278
+ * and a spawn without a tier falls back to the configured default.
279
+ */
280
+ function parseTier(val: unknown, path: string, warn: WarningSink): string | undefined {
281
+ if (val === undefined || val === null) return undefined;
282
+ if (isValidAgentTierKey(val)) return val;
283
+ warn(
284
+ `Ignoring invalid tier in ${path}: expected a non-empty single-word key`,
285
+ `tier:${warningIdentity(path)}`,
286
+ );
287
+ return undefined;
288
+ }
289
+
269
290
  /** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
270
291
  function nonNegativeInt(val: unknown): number | undefined {
271
292
  return typeof val === "number" && val >= 0 ? val : undefined;
package/src/index.ts CHANGED
@@ -27,6 +27,14 @@ import {
27
27
  type ManagedSpawnTombstone,
28
28
  } from "./agent-manager.js";
29
29
  import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
30
+ import {
31
+ buildAgentTierListText,
32
+ buildAgentTierParameterDescription,
33
+ buildCompactAgentTierListText,
34
+ getAgentTiersSettings,
35
+ getDefaultAgentTierText,
36
+ setAgentTiersSettings,
37
+ } from "./agent-tiers.js";
30
38
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
31
39
  import { inChildSessionContext } from "./child-context.js";
32
40
  import {
@@ -1000,7 +1008,7 @@ function activateRootRuntime(
1000
1008
  ...(effectiveTier === undefined ? {} : { tier: effectiveTier }),
1001
1009
  };
1002
1010
  const customConfig = getAgentConfig(dispatch.type);
1003
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, { tier: effectiveTier });
1011
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, { workflowTier: effectiveTier });
1004
1012
  // A managed request cannot carry a model override, but an agent's frontmatter
1005
1013
  // model still needs the same scope validation as a normal Agent call when no
1006
1014
  // workflow tier is active. Tiered runs validate their effective policy inside
@@ -1227,6 +1235,7 @@ function activateRootRuntime(
1227
1235
  setMaxSubagentDepth,
1228
1236
  setFallbackSubagent,
1229
1237
  setWorkflow: setWorkflowSettings,
1238
+ setAgentTiers: setAgentTiersSettings,
1230
1239
  });
1231
1240
  pi.events.emit("subagents:settings_loaded", { settings: startupSettings });
1232
1241
 
@@ -1257,8 +1266,16 @@ function activateRootRuntime(
1257
1266
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
1258
1267
  // the same load-bearing facts as the full version at ~75% fewer tokens, for
1259
1268
  // small/local models. Per-option details live in the param descriptions.
1269
+ // The catalogue is injected into the description rather than left to a lookup
1270
+ // tool: the host has to pick a tier on its first call, and a tool it must
1271
+ // remember to call first is a tool it will skip.
1272
+ const tierListText = buildAgentTierListText();
1273
+ const compactTierListText = buildCompactAgentTierListText();
1274
+ const tierSection = tierListText ? `\n\n${tierListText}` : "";
1275
+ const compactTierSection = compactTierListText ? `\n\n${compactTierListText}` : "";
1276
+
1260
1277
  const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
1261
- ${buildCompactTypeListText()}
1278
+ ${buildCompactTypeListText()}${compactTierSection}
1262
1279
 
1263
1280
  Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
1264
1281
 
@@ -1274,7 +1291,7 @@ Notes:
1274
1291
  Available agent types and the tools they have access to:
1275
1292
  ${buildTypeListText()}
1276
1293
 
1277
- Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
1294
+ Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.${tierSection}
1278
1295
 
1279
1296
  When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
1280
1297
 
@@ -1294,8 +1311,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1294
1311
  - Use steer_subagent to send mid-run messages to a running background agent.
1295
1312
  - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
1296
1313
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
1297
- - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
1298
- - Use thinking to control extended thinking level.
1314
+ - Use tier to pick the model profile for this spawn, by name. A tier overrides the agent's own default tier. Model and thinking are not callable parameters — they are what a tier resolves to.
1299
1315
  - Use inherit_context if the agent needs the parent conversation history.
1300
1316
  - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.${scheduleGuideline}
1301
1317
 
@@ -1320,6 +1336,12 @@ Terse command-style prompts produce shallow, generic work.
1320
1336
  const vars: Record<string, () => string> = {
1321
1337
  typeList: buildTypeListText,
1322
1338
  compactTypeList: buildCompactTypeListText,
1339
+ // Both carry their own leading blank line, so a template that drops the
1340
+ // placeholder inline renders byte-identically to the built-in description
1341
+ // whether or not any tier is configured.
1342
+ tierList: () => tierSection,
1343
+ compactTierList: () => compactTierSection,
1344
+ defaultTier: getDefaultAgentTierText,
1323
1345
  agentDir: getAgentDir,
1324
1346
  scheduleGuideline: () => scheduleGuideline,
1325
1347
  };
@@ -1380,15 +1402,9 @@ Terse command-style prompts produce shallow, generic work.
1380
1402
  subagent_type: Type.String({
1381
1403
  description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
1382
1404
  }),
1383
- model: Type.Optional(
1384
- Type.String({
1385
- description:
1386
- 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
1387
- }),
1388
- ),
1389
- thinking: Type.Optional(
1405
+ tier: Type.Optional(
1390
1406
  Type.String({
1391
- description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
1407
+ description: buildAgentTierParameterDescription(),
1392
1408
  }),
1393
1409
  ),
1394
1410
  max_turns: Type.Optional(
@@ -1746,6 +1762,7 @@ Terse command-style prompts produce shallow, generic work.
1746
1762
  isolated,
1747
1763
  inheritContext,
1748
1764
  thinkingLevel: thinking,
1765
+ agentTier: resolvedConfig.requestedAgentTier,
1749
1766
  isBackground: true,
1750
1767
  isolation,
1751
1768
  invocation: agentInvocation,
@@ -1875,6 +1892,7 @@ Terse command-style prompts produce shallow, generic work.
1875
1892
  isolated,
1876
1893
  inheritContext,
1877
1894
  thinkingLevel: thinking,
1895
+ agentTier: resolvedConfig.requestedAgentTier,
1878
1896
  isolation,
1879
1897
  invocation: agentInvocation,
1880
1898
  signal,
@@ -2763,6 +2781,15 @@ Do not wrap the response in a markdown code fence. Return only the file contents
2763
2781
  (getWorkflowSettings().blockedTiers?.length ?? 0) > 0
2764
2782
  ? { workflow: getWorkflowSettings() }
2765
2783
  : {}),
2784
+ // Same shape as the workflow block above: written back only when the user
2785
+ // actually configured tiers, so the snapshot never materializes an empty
2786
+ // catalogue into the project settings file.
2787
+ ...(Object.keys(getAgentTiersSettings().profiles ?? {}).length > 0 ||
2788
+ getAgentTiersSettings().defaultTier !== undefined ||
2789
+ getAgentTiersSettings().blockedDefaultTier === true ||
2790
+ (getAgentTiersSettings().blockedProfiles?.length ?? 0) > 0
2791
+ ? { agentTiers: getAgentTiersSettings() }
2792
+ : {}),
2766
2793
  // Deliberately NOT `?? "general-purpose"`: every settings change writes the
2767
2794
  // whole snapshot, and materializing the implicit default would turn it into
2768
2795
  // explicit configuration — which then fails loudly if general-purpose later
@@ -1,7 +1,23 @@
1
1
  import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
2
- import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
2
+ import type {
3
+ AgentConfig,
4
+ IsolationMode,
5
+ JoinMode,
6
+ ThinkingLevel,
7
+ } from "./types.js";
3
8
 
4
9
  interface AgentInvocationParams {
10
+ /**
11
+ * User-named model tier requested at the call site. This is the only model
12
+ * control the LLM-facing `Agent` tool exposes; `model` and `thinking` below
13
+ * are reachable only from programmatic callers and the legacy RPC.
14
+ */
15
+ 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;
5
21
  model?: string;
6
22
  thinking?: string;
7
23
  max_turns?: number;
@@ -9,7 +25,6 @@ interface AgentInvocationParams {
9
25
  inherit_context?: boolean;
10
26
  isolated?: boolean;
11
27
  isolation?: IsolationMode;
12
- tier?: WorkflowTier;
13
28
  }
14
29
 
15
30
  export function resolveAgentInvocationConfig(
@@ -18,7 +33,9 @@ export function resolveAgentInvocationConfig(
18
33
  ): {
19
34
  modelInput?: string;
20
35
  modelFromParams: boolean;
21
- tier?: WorkflowTier;
36
+ /** Passed to `resolveAgentTier` as the requested tier; precedence lives there. */
37
+ requestedAgentTier?: string;
38
+ workflowTier?: WorkflowTier;
22
39
  thinking?: ThinkingLevel;
23
40
  maxTurns?: number;
24
41
  inheritContext: boolean;
@@ -28,17 +45,25 @@ export function resolveAgentInvocationConfig(
28
45
  } {
29
46
  return {
30
47
  modelInput: agentConfig?.model ?? params.model,
31
- tier: params.tier,
48
+ requestedAgentTier: params.tier,
49
+ workflowTier: params.workflowTier,
32
50
  modelFromParams: agentConfig?.model == null && params.model != null,
33
- thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
51
+ thinking: (agentConfig?.thinking ?? params.thinking) as
52
+ | ThinkingLevel
53
+ | undefined,
34
54
  maxTurns: agentConfig?.maxTurns ?? params.max_turns,
35
- inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
36
- runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
55
+ inheritContext:
56
+ agentConfig?.inheritContext ?? params.inherit_context ?? false,
57
+ runInBackground:
58
+ agentConfig?.runInBackground ?? params.run_in_background ?? false,
37
59
  isolated: agentConfig?.isolated ?? params.isolated ?? false,
38
60
  isolation: agentConfig?.isolation ?? params.isolation,
39
61
  };
40
62
  }
41
63
 
42
- export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
64
+ export function resolveJoinMode(
65
+ defaultJoinMode: JoinMode,
66
+ runInBackground: boolean,
67
+ ): JoinMode | undefined {
43
68
  return runInBackground ? defaultJoinMode : undefined;
44
69
  }
@@ -8,6 +8,7 @@ import {
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { Type } from "@sinclair/typebox";
10
10
  import { abortable } from "./abortable.js";
11
+ import { buildAgentTierParameterDescription } from "./agent-tiers.js";
11
12
  import {
12
13
  buildAgentRegistry,
13
14
  getAgentConfigIn,
@@ -50,6 +51,8 @@ const NESTED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"] as
50
51
 
51
52
  interface NestedSpawnOptions {
52
53
  description: string;
54
+ /** User-named model tier; resolved by the runner like every other spawn path. */
55
+ agentTier?: string;
53
56
  model?: Model<any>;
54
57
  maxTurns?: number;
55
58
  isolated?: boolean;
@@ -168,8 +171,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
168
171
  prompt: Type.String({ description: "Self-contained task for the nested agent." }),
169
172
  description: Type.String({ description: "Short 3-5 word task description." }),
170
173
  subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.` }),
171
- model: Type.Optional(Type.String({ description: "Optional provider/model override." })),
172
- thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
174
+ tier: Type.Optional(Type.String({ description: buildAgentTierParameterDescription() })),
173
175
  max_turns: Type.Optional(Type.Number({ minimum: 1 })),
174
176
  run_in_background: Type.Optional(Type.Boolean()),
175
177
  resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
@@ -252,6 +254,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
252
254
  const childDepth = context.depth + 1;
253
255
  const options: NestedSpawnOptions = {
254
256
  description: params.description,
257
+ agentTier: invocation.requestedAgentTier,
255
258
  model,
256
259
  maxTurns: invocation.maxTurns,
257
260
  isolated: invocation.isolated,
@@ -259,6 +262,9 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
259
262
  thinkingLevel: invocation.thinking,
260
263
  isolation: invocation.isolation,
261
264
  invocation: {
265
+ ...(invocation.requestedAgentTier === undefined
266
+ ? {}
267
+ : { agentTier: invocation.requestedAgentTier }),
262
268
  thinking: invocation.thinking,
263
269
  maxTurns: invocation.maxTurns,
264
270
  isolated: invocation.isolated,
package/src/schedule.ts CHANGED
@@ -256,6 +256,9 @@ export class SubagentScheduler {
256
256
  maxTurns: job.max_turns,
257
257
  isolated: job.isolated,
258
258
  thinkingLevel: job.thinking,
259
+ // Resolved at fire time against the catalogue as it is then, not as it
260
+ // was when the job was created: a schedule outlives config edits.
261
+ agentTier: job.tier,
259
262
  isolation: job.isolation,
260
263
  });
261
264
  } catch (err) {