@signalridge/pi-subagents 1.2.0 → 1.4.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,336 @@
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
+ * Every tier name that is referenced but not defined.
242
+ *
243
+ * The resolver refuses these at spawn time anyway, but a `defaultTier` typo
244
+ * would otherwise sit quiet until the first agent that names no tier — which
245
+ * may be minutes into a session, in the middle of something. Checking the
246
+ * references once, when settings and agents are loaded, moves that discovery to
247
+ * where it is cheap and where the fix is obvious.
248
+ *
249
+ * `agentNames` maps an agent to the tier its frontmatter asks for, so a typo in
250
+ * one agent file is reported the same way as one in `defaultTier`.
251
+ */
252
+ export function findUnknownAgentTierReferences(
253
+ settings: AgentTiersSettings,
254
+ agentTiers: ReadonlyMap<string, string> = new Map(),
255
+ ): string[] {
256
+ const defined = new Set(Object.keys(settings.profiles ?? {}));
257
+ // With nothing configured there is no catalogue to be wrong about; the
258
+ // resolver simply never applies a tier.
259
+ if (defined.size === 0 && settings.defaultTier === undefined) return [];
260
+
261
+ const problems: string[] = [];
262
+ if (settings.defaultTier !== undefined && !defined.has(settings.defaultTier)) {
263
+ problems.push(
264
+ `agentTiers.defaultTier is "${settings.defaultTier}", which is not a defined tier. ` +
265
+ `Available: ${tierKeyList(settings)}`,
266
+ );
267
+ }
268
+ for (const [agent, tier] of [...agentTiers].sort(([a], [b]) => a.localeCompare(b))) {
269
+ if (!defined.has(tier)) {
270
+ problems.push(
271
+ `Agent "${agent}" asks for tier "${tier}", which is not a defined tier. ` +
272
+ `Available: ${tierKeyList(settings)}`,
273
+ );
274
+ }
275
+ }
276
+ return problems;
277
+ }
278
+
279
+ /**
280
+ * The tier catalogue, rendered for the `Agent` tool description.
281
+ *
282
+ * The host has to know the vocabulary before its first call, so this is injected
283
+ * into the tool description at registration rather than exposed through a lookup
284
+ * tool the model would have to remember to call. Only names, descriptions,
285
+ * models and thinking levels appear — nothing here reads credentials.
286
+ */
287
+ export function buildAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
288
+ const keys = knownTierKeys(settings);
289
+ if (keys.length === 0) return "";
290
+
291
+ const entries = keys.map((key) => {
292
+ const profile = settings.profiles?.[key];
293
+ if (!profile) return `- ${key}`;
294
+ // A profile without its own description is still worth listing; the key is
295
+ // the description in that case, which is what a terse config intends.
296
+ const summary = profile.description ?? key;
297
+ return `- ${key}: ${summary}\n model: ${profile.model}\n thinking: ${profile.thinking}`;
298
+ });
299
+
300
+ const defaultLine =
301
+ settings.defaultTier !== undefined ? `\n\nDefault tier: ${settings.defaultTier}` : "";
302
+ 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.`;
303
+ }
304
+
305
+ /** One line per tier, for the compact tool description. */
306
+ export function buildCompactAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
307
+ const keys = knownTierKeys(settings);
308
+ if (keys.length === 0) return "";
309
+
310
+ const entries = keys.map((key) => {
311
+ const profile = settings.profiles?.[key];
312
+ if (!profile) return `- ${key}`;
313
+ return `- ${key}: ${profile.description ?? key} (${profile.model}, thinking ${profile.thinking})`;
314
+ });
315
+ const defaultSuffix = settings.defaultTier !== undefined ? ` Default: ${settings.defaultTier}.` : "";
316
+ return `Agent tiers (pass \`tier\`, never model/thinking):\n${entries.join("\n")}${defaultSuffix}`;
317
+ }
318
+
319
+ /** The configured default tier key, or "" when none is set. */
320
+ export function getDefaultAgentTierText(settings: AgentTiersSettings = agentTiersSettings): string {
321
+ return settings.defaultTier ?? "";
322
+ }
323
+
324
+ /** Description for the `tier` parameter, naming the keys this workspace defines. */
325
+ export function buildAgentTierParameterDescription(settings: AgentTiersSettings = agentTiersSettings): string {
326
+ const keys = knownTierKeys(settings);
327
+ const available = keys.length > 0 ? keys.join(", ") : "none configured";
328
+ const fallback =
329
+ settings.defaultTier !== undefined
330
+ ? ` Omit to use the agent's own tier, or "${settings.defaultTier}".`
331
+ : " Omit to use the agent's own tier.";
332
+ return (
333
+ `Model tier for this spawn, chosen by name. Available: ${available}.${fallback}` +
334
+ " A tier overrides the agent's default. Unknown tiers are rejected rather than substituted."
335
+ );
336
+ }
@@ -5,9 +5,10 @@
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
- import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
11
+ import type { AgentConfig, MemoryScope } from "./types.js";
11
12
  import { sanitizeDisplayText } from "./ui/safe-text.js";
12
13
 
13
14
  /**
@@ -163,6 +164,7 @@ function loadFromDir(
163
164
  const { frontmatter: fm, body } = parsed;
164
165
 
165
166
  const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
167
+ warnLegacyModelFields(fm, path, warn);
166
168
 
167
169
  agents.set(name, {
168
170
  name,
@@ -174,8 +176,7 @@ function loadFromDir(
174
176
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
175
177
  excludeExtensions: csvListOptional(fm.exclude_extensions),
176
178
  skills: inheritField(fm.skills ?? fm.inherit_skills),
177
- model: str(fm.model),
178
- thinking: str(fm.thinking) as ThinkingLevel | undefined,
179
+ agentTier: parseTier(fm.tier, path, warn),
179
180
  maxTurns: nonNegativeInt(fm.max_turns),
180
181
  persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
181
182
  outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
@@ -195,6 +196,25 @@ function loadFromDir(
195
196
  priorities.set(name, priority);
196
197
  }
197
198
  }
199
+ /**
200
+ * Report a `model:`/`thinking:` pin left over from before tiers.
201
+ *
202
+ * An agent file no longer chooses its own model — the tier catalogue in
203
+ * `subagents.json` does, and a per-file pin would be a way around it. The file
204
+ * still loads: a stale pin is a migration the author has not done yet, not a
205
+ * reason to take the agent away mid-session. It simply has no effect, and the
206
+ * warning names the file so it can be fixed.
207
+ */
208
+ function warnLegacyModelFields(fm: Record<string, unknown>, path: string, warn: WarningSink): void {
209
+ const present = ["model", "thinking"].filter((field) => fm[field] != null);
210
+ if (present.length === 0) return;
211
+ warn(
212
+ `Ignoring ${present.join(" and ")} in ${path}: agents pick a model with "tier:" now. ` +
213
+ `Replace it with a tier from agentTiers.profiles, or remove it to use the default tier.`,
214
+ `legacy-model:${warningIdentity(path)}`,
215
+ );
216
+ }
217
+
198
218
  /** Read and parse one agent file, warning or throwing with its path on failure. */
199
219
  function readAgentFile(
200
220
  path: string,
@@ -266,6 +286,28 @@ function label(val: unknown): string | undefined {
266
286
  return raw === undefined ? undefined : sanitizeDisplayText(raw);
267
287
  }
268
288
 
289
+ /**
290
+ * Parse the agent's default model tier.
291
+ *
292
+ * Only the shape is checked here; whether the key names a configured profile is
293
+ * decided at spawn time, because the catalogue lives in `subagents.json` and may
294
+ * legitimately change after this file was read. A malformed key is dropped with
295
+ * a warning rather than failing the load: the rest of the agent is still usable,
296
+ * and a spawn without a tier falls back to the configured default.
297
+ */
298
+ function parseTier(val: unknown, path: string, warn: WarningSink): string | undefined {
299
+ if (val === undefined || val === null) return undefined;
300
+ // Deliberately not "no tier means no model": which model an agent runs is
301
+ // decided by the tier catalogue, so an agent that names no tier falls to
302
+ // `agentTiers.defaultTier` rather than pinning anything itself.
303
+ if (isValidAgentTierKey(val)) return val;
304
+ warn(
305
+ `Ignoring invalid tier in ${path}: expected a non-empty single-word key`,
306
+ `tier:${warningIdentity(path)}`,
307
+ );
308
+ return undefined;
309
+ }
310
+
269
311
  /** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
270
312
  function nonNegativeInt(val: unknown): number | undefined {
271
313
  return typeof val === "number" && val >= 0 ? val : undefined;
@@ -34,10 +34,11 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
34
34
  builtinToolNames: READ_ONLY_TOOLS,
35
35
  extensions: true,
36
36
  skills: true,
37
- // Fast/cheap model for read-only search. Provider-preferred but resilient:
38
- // resolveModel matches this fuzzily (date-stamp optional) and falls back to
39
- // the same model under another provider if anthropic doesn't expose it.
40
- model: "anthropic/claude-haiku-4-5",
37
+ // No model pin. Which model a subagent runs is the tier catalogue's
38
+ // decision; a built-in that pinned one would be the same end-run around it
39
+ // that agent frontmatter is no longer allowed to make, and it would name a
40
+ // vendor on a machine that may not have it. With no tier configured this
41
+ // inherits the parent's model, which is the documented fallback.
41
42
  systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
42
43
  You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
43
44
  Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
package/src/index.ts CHANGED
@@ -27,6 +27,15 @@ 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
+ findUnknownAgentTierReferences,
35
+ getAgentTiersSettings,
36
+ getDefaultAgentTierText,
37
+ setAgentTiersSettings,
38
+ } from "./agent-tiers.js";
30
39
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
31
40
  import { inChildSessionContext } from "./child-context.js";
32
41
  import {
@@ -1000,7 +1009,7 @@ function activateRootRuntime(
1000
1009
  ...(effectiveTier === undefined ? {} : { tier: effectiveTier }),
1001
1010
  };
1002
1011
  const customConfig = getAgentConfig(dispatch.type);
1003
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, { tier: effectiveTier });
1012
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, { workflowTier: effectiveTier });
1004
1013
  // A managed request cannot carry a model override, but an agent's frontmatter
1005
1014
  // model still needs the same scope validation as a normal Agent call when no
1006
1015
  // workflow tier is active. Tiered runs validate their effective policy inside
@@ -1183,7 +1192,7 @@ function activateRootRuntime(
1183
1192
 
1184
1193
  return available.map((name) => {
1185
1194
  const cfg = getAgentConfig(name);
1186
- const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
1195
+ const modelSuffix = cfg?.agentTier ? ` (tier: ${cfg.agentTier})` : "";
1187
1196
  const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
1188
1197
  return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
1189
1198
  }).join("\n");
@@ -1227,9 +1236,25 @@ function activateRootRuntime(
1227
1236
  setMaxSubagentDepth,
1228
1237
  setFallbackSubagent,
1229
1238
  setWorkflow: setWorkflowSettings,
1239
+ setAgentTiers: setAgentTiersSettings,
1230
1240
  });
1231
1241
  pi.events.emit("subagents:settings_loaded", { settings: startupSettings });
1232
1242
 
1243
+ // A tier that nothing defines is a typo, and the resolver would only reach it
1244
+ // on the first spawn that needs it — possibly minutes in, mid-task. Report it
1245
+ // now, while the fix is obvious and nothing has run.
1246
+ const tierReferenceProblems = findUnknownAgentTierReferences(
1247
+ getAgentTiersSettings(),
1248
+ new Map(
1249
+ getAvailableTypes()
1250
+ .map((name): [string, string | undefined] => [name, getAgentConfig(name)?.agentTier])
1251
+ .filter((entry): entry is [string, string] => entry[1] !== undefined),
1252
+ ),
1253
+ );
1254
+ for (const problem of tierReferenceProblems) {
1255
+ console.warn(`[pi-subagents] ${problem}`);
1256
+ }
1257
+
1233
1258
  // ---- Agent tool ----
1234
1259
 
1235
1260
  // Schedule param + its guideline are gated on `schedulingEnabled` (read once
@@ -1257,8 +1282,16 @@ function activateRootRuntime(
1257
1282
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
1258
1283
  // the same load-bearing facts as the full version at ~75% fewer tokens, for
1259
1284
  // small/local models. Per-option details live in the param descriptions.
1285
+ // The catalogue is injected into the description rather than left to a lookup
1286
+ // tool: the host has to pick a tier on its first call, and a tool it must
1287
+ // remember to call first is a tool it will skip.
1288
+ const tierListText = buildAgentTierListText();
1289
+ const compactTierListText = buildCompactAgentTierListText();
1290
+ const tierSection = tierListText ? `\n\n${tierListText}` : "";
1291
+ const compactTierSection = compactTierListText ? `\n\n${compactTierListText}` : "";
1292
+
1260
1293
  const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
1261
- ${buildCompactTypeListText()}
1294
+ ${buildCompactTypeListText()}${compactTierSection}
1262
1295
 
1263
1296
  Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
1264
1297
 
@@ -1274,7 +1307,7 @@ Notes:
1274
1307
  Available agent types and the tools they have access to:
1275
1308
  ${buildTypeListText()}
1276
1309
 
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.
1310
+ 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
1311
 
1279
1312
  When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
1280
1313
 
@@ -1294,8 +1327,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1294
1327
  - Use steer_subagent to send mid-run messages to a running background agent.
1295
1328
  - 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
1329
  - 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.
1330
+ - 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
1331
  - Use inherit_context if the agent needs the parent conversation history.
1300
1332
  - 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
1333
 
@@ -1320,6 +1352,12 @@ Terse command-style prompts produce shallow, generic work.
1320
1352
  const vars: Record<string, () => string> = {
1321
1353
  typeList: buildTypeListText,
1322
1354
  compactTypeList: buildCompactTypeListText,
1355
+ // Both carry their own leading blank line, so a template that drops the
1356
+ // placeholder inline renders byte-identically to the built-in description
1357
+ // whether or not any tier is configured.
1358
+ tierList: () => tierSection,
1359
+ compactTierList: () => compactTierSection,
1360
+ defaultTier: getDefaultAgentTierText,
1323
1361
  agentDir: getAgentDir,
1324
1362
  scheduleGuideline: () => scheduleGuideline,
1325
1363
  };
@@ -1380,15 +1418,9 @@ Terse command-style prompts produce shallow, generic work.
1380
1418
  subagent_type: Type.String({
1381
1419
  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
1420
  }),
1383
- model: Type.Optional(
1421
+ tier: Type.Optional(
1384
1422
  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(
1390
- Type.String({
1391
- description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
1423
+ description: buildAgentTierParameterDescription(),
1392
1424
  }),
1393
1425
  ),
1394
1426
  max_turns: Type.Optional(
@@ -1746,6 +1778,7 @@ Terse command-style prompts produce shallow, generic work.
1746
1778
  isolated,
1747
1779
  inheritContext,
1748
1780
  thinkingLevel: thinking,
1781
+ agentTier: resolvedConfig.requestedAgentTier,
1749
1782
  isBackground: true,
1750
1783
  isolation,
1751
1784
  invocation: agentInvocation,
@@ -1875,6 +1908,7 @@ Terse command-style prompts produce shallow, generic work.
1875
1908
  isolated,
1876
1909
  inheritContext,
1877
1910
  thinkingLevel: thinking,
1911
+ agentTier: resolvedConfig.requestedAgentTier,
1878
1912
  isolation,
1879
1913
  invocation: agentInvocation,
1880
1914
  signal,
@@ -2763,6 +2797,15 @@ Do not wrap the response in a markdown code fence. Return only the file contents
2763
2797
  (getWorkflowSettings().blockedTiers?.length ?? 0) > 0
2764
2798
  ? { workflow: getWorkflowSettings() }
2765
2799
  : {}),
2800
+ // Same shape as the workflow block above: written back only when the user
2801
+ // actually configured tiers, so the snapshot never materializes an empty
2802
+ // catalogue into the project settings file.
2803
+ ...(Object.keys(getAgentTiersSettings().profiles ?? {}).length > 0 ||
2804
+ getAgentTiersSettings().defaultTier !== undefined ||
2805
+ getAgentTiersSettings().blockedDefaultTier === true ||
2806
+ (getAgentTiersSettings().blockedProfiles?.length ?? 0) > 0
2807
+ ? { agentTiers: getAgentTiersSettings() }
2808
+ : {}),
2766
2809
  // Deliberately NOT `?? "general-purpose"`: every settings change writes the
2767
2810
  // whole snapshot, and materializing the implicit default would turn it into
2768
2811
  // 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
  }