@signalridge/pi-subagents 1.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0
4
+ ### Minor Changes
5
+
6
+ - 72be09a: An agent no longer picks its own model.
7
+
8
+ `model:` and `thinking:` in agent frontmatter are no longer read. Which model a
9
+ subagent runs is the tier catalogue's decision, and a per-file pin was a way
10
+ around it — silently, since nothing warned unless the file also named a tier. A
11
+ file that still carries them loads and runs exactly as before; the two lines
12
+ have no effect, and a warning names the file so the migration can be finished
13
+ one agent at a time.
14
+
15
+ The built-in agents drop their own pin for the same reason: `Explore` named
16
+ `anthropic/claude-haiku-4-5`, which is both an end-run around the catalogue and
17
+ a vendor the machine may not have.
18
+
19
+ The resulting fallback is simple and worth stating: with no tier passed, none in
20
+ the agent, and no `agentTiers.defaultTier`, a subagent runs on the parent
21
+ session's model.
22
+
23
+ A `defaultTier` — or an agent's `tier:` — that names no defined profile is now
24
+ reported at startup with the available keys, rather than waiting for the first
25
+ spawn that needs it. `/agents` no longer advertises a pinned model in the type
26
+ list and writes `tier:` instead of `model:` when it regenerates an agent file.
27
+
28
+ Programmatic callers and the legacy RPC still accept `model`/`thinking`. That is
29
+ an escape hatch for code, not a way to configure an agent.
30
+
3
31
  ## 1.3.0
4
32
  ### Minor Changes
5
33
 
package/README.md CHANGED
@@ -231,8 +231,8 @@ All fields are optional — sensible defaults for everything.
231
231
  | `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
232
232
  | `isolation` | — | Set to `worktree` to run in an isolated git worktree |
233
233
  | `tier` | none | This agent's default model tier, by name, from `agentTiers.profiles`. A tier passed at the call site overrides it. When set, it wins over `model`/`thinking` below — see [Model tiers](#model-tiers) |
234
- | `model` | inherit parent | **Legacy.** Model `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp are interchangeable) and falls back to the same model under another provider if the named one doesn't have it. Ignored when a tier applies |
235
- | `thinking` | inherit | **Legacy.** off, minimal, low, medium, high, xhigh, max — actual availability depends on your pi version and model; pi clamps unsupported levels down. Ignored when a tier applies |
234
+ | ~~`model`~~ | | **Removed.** An agent no longer chooses its own model; use `tier`. A file that still has it loads normally, with a warning naming it the line simply has no effect |
235
+ | ~~`thinking`~~ | | **Removed**, same as `model` above |
236
236
  | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
237
237
  | `persist_session` | `false` | Persist this subagent as a normal pi session instead of keeping the session in memory only. The subagent's `.output` transcript is still written either way unless `output_transcript: false` |
238
238
  | `output_transcript` | `true` (or `subagents.json` `outputTranscript`) | Write this subagent's `.output` transcript; when set, overrides the `subagents.json` `outputTranscript` default. Set `false` to write no transcript file or path. Governs only the transcript — independent of `persist_session`, `isolation: worktree`, and `memory:` |
@@ -464,15 +464,21 @@ the next pi session, since the description is built once at registration.
464
464
  1. `tier` passed to the `Agent` call
465
465
  2. `tier:` in the agent's frontmatter
466
466
  3. `agentTiers.defaultTier`
467
- 4. legacy `model:`/`thinking:` in the agent's frontmatter
468
- 5. the parent session's model and thinking
467
+ 4. the parent session's model and thinking
469
468
 
470
- A tier that applies decides both fields outright it is current policy, while a
471
- per-agent `model:` pin is the older, weaker statement of the same thing. An agent
472
- carrying both logs a warning naming the file to clean up.
469
+ There is no fifth step: an agent cannot pin its own model. `model:`/`thinking:`
470
+ in frontmatter are read only to warn that they are stale, and the built-in
471
+ agents pin nothing either. With no tier anywhere none passed, none in the
472
+ agent, no `defaultTier` — a subagent runs on the parent session's model, which
473
+ is what a workspace that has configured no tiers gets.
473
474
 
474
475
  ### Refusals
475
476
 
477
+ A `defaultTier`, or an agent's `tier:`, that names no defined profile is
478
+ reported at **startup**, listing the available keys — a typo there would
479
+ otherwise sit quiet until the first spawn that needed it, possibly minutes into
480
+ a session.
481
+
476
482
  These fail **before** the spawn, with the tier key and where it came from named.
477
483
  None of them silently substitutes another model:
478
484
 
@@ -501,11 +507,14 @@ standing in for the other.
501
507
 
502
508
  ### Migrating from `model:`/`thinking:`
503
509
 
504
- Existing agents keep working: with no `agentTiers` configured, resolution falls
505
- through to the legacy fields exactly as before. To migrate, define the profiles
506
- once and replace the per-agent pins with `tier: <name>`. Programmatic callers and
507
- the legacy RPC may still pass `model`/`thinking` directly; only the LLM-facing
508
- `Agent` and nested-Agent schemas dropped them.
510
+ Define the profiles once, then replace each agent's `model:`/`thinking:` with
511
+ `tier: <name>`. Files that still carry the old fields load and run the fields
512
+ are ignored, with a warning naming the file so the migration can be done one
513
+ agent at a time. Until an agent names a tier it uses `defaultTier`, or the
514
+ parent's model when none is set.
515
+
516
+ Programmatic callers and the legacy RPC may still pass `model`/`thinking`
517
+ directly. That is the escape hatch for code, not a way to configure an agent.
509
518
 
510
519
  ## Model Scope
511
520
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-subagents",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
5
5
  "author": "tintinweb and signalridge contributors",
6
6
  "license": "MIT",
@@ -691,8 +691,9 @@ export function serializeAgentFile(cfg: AgentConfig): string {
691
691
  fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
692
692
  if (cfg.displayName) fmFields.push(`display_name: ${JSON.stringify(cfg.displayName)}`);
693
693
  fmFields.push(`tools: ${formatYamlScalar(formatToolsField(cfg))}`);
694
- if (cfg.model) fmFields.push(`model: ${JSON.stringify(cfg.model)}`);
695
- if (cfg.thinking) fmFields.push(`thinking: ${formatYamlScalar(cfg.thinking)}`);
694
+ // Never model:/thinking: — the loader ignores them, so writing them back
695
+ // would recreate a pin that looks effective and is not.
696
+ if (cfg.agentTier) fmFields.push(`tier: ${formatYamlScalar(cfg.agentTier)}`);
696
697
  if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
697
698
  if (cfg.persistSession) fmFields.push("persist_session: true");
698
699
  if (cfg.sessionDir) fmFields.push(`session_dir: ${JSON.stringify(cfg.sessionDir)}`);
@@ -644,15 +644,7 @@ export async function runAgent(
644
644
  parentThinking,
645
645
  modelRegistry: ctx.modelRegistry,
646
646
  });
647
- if (agentTierResolution.snapshot) {
648
- options.onAgentTierResolved?.(agentTierResolution.snapshot);
649
- if (agentConfig?.model !== undefined || agentConfig?.thinking !== undefined) {
650
- console.warn(
651
- `[pi-subagents] Agent "${agentConfig.name}" sets both tier "${agentTierResolution.snapshot.tier}" ` +
652
- `and legacy model/thinking frontmatter; the tier wins. Remove model:/thinking: from the agent file.`,
653
- );
654
- }
655
- }
647
+ if (agentTierResolution.snapshot) options.onAgentTierResolved?.(agentTierResolution.snapshot);
656
648
 
657
649
  // Resolve working directory: worktree override > parent cwd
658
650
  const effectiveCwd = options.cwd ?? ctx.cwd;
@@ -237,6 +237,45 @@ export function resolveAgentTier(input: ResolveAgentTierInput): AgentTierResolut
237
237
  return { model, thinkingLevel, snapshot };
238
238
  }
239
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
+
240
279
  /**
241
280
  * The tier catalogue, rendered for the `Agent` tool description.
242
281
  *
@@ -8,7 +8,7 @@ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { isValidAgentTierKey } from "./agent-tiers.js";
9
9
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
10
10
  import { DEFAULT_AGENTS } from "./default-agents.js";
11
- import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
11
+ import type { AgentConfig, MemoryScope } from "./types.js";
12
12
  import { sanitizeDisplayText } from "./ui/safe-text.js";
13
13
 
14
14
  /**
@@ -164,6 +164,7 @@ function loadFromDir(
164
164
  const { frontmatter: fm, body } = parsed;
165
165
 
166
166
  const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
167
+ warnLegacyModelFields(fm, path, warn);
167
168
 
168
169
  agents.set(name, {
169
170
  name,
@@ -176,8 +177,6 @@ function loadFromDir(
176
177
  excludeExtensions: csvListOptional(fm.exclude_extensions),
177
178
  skills: inheritField(fm.skills ?? fm.inherit_skills),
178
179
  agentTier: parseTier(fm.tier, path, warn),
179
- model: str(fm.model),
180
- thinking: str(fm.thinking) as ThinkingLevel | undefined,
181
180
  maxTurns: nonNegativeInt(fm.max_turns),
182
181
  persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
183
182
  outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
@@ -197,6 +196,25 @@ function loadFromDir(
197
196
  priorities.set(name, priority);
198
197
  }
199
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
+
200
218
  /** Read and parse one agent file, warning or throwing with its path on failure. */
201
219
  function readAgentFile(
202
220
  path: string,
@@ -279,6 +297,9 @@ function label(val: unknown): string | undefined {
279
297
  */
280
298
  function parseTier(val: unknown, path: string, warn: WarningSink): string | undefined {
281
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.
282
303
  if (isValidAgentTierKey(val)) return val;
283
304
  warn(
284
305
  `Ignoring invalid tier in ${path}: expected a non-empty single-word key`,
@@ -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
@@ -31,6 +31,7 @@ import {
31
31
  buildAgentTierListText,
32
32
  buildAgentTierParameterDescription,
33
33
  buildCompactAgentTierListText,
34
+ findUnknownAgentTierReferences,
34
35
  getAgentTiersSettings,
35
36
  getDefaultAgentTierText,
36
37
  setAgentTiersSettings,
@@ -1191,7 +1192,7 @@ function activateRootRuntime(
1191
1192
 
1192
1193
  return available.map((name) => {
1193
1194
  const cfg = getAgentConfig(name);
1194
- const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
1195
+ const modelSuffix = cfg?.agentTier ? ` (tier: ${cfg.agentTier})` : "";
1195
1196
  const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
1196
1197
  return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
1197
1198
  }).join("\n");
@@ -1239,6 +1240,21 @@ function activateRootRuntime(
1239
1240
  });
1240
1241
  pi.events.emit("subagents:settings_loaded", { settings: startupSettings });
1241
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
+
1242
1258
  // ---- Agent tool ----
1243
1259
 
1244
1260
  // Schedule param + its guideline are gated on `schedulingEnabled` (read once
package/src/types.ts CHANGED
@@ -59,7 +59,14 @@ export interface AgentConfig {
59
59
  * tiers existed.
60
60
  */
61
61
  agentTier?: string;
62
+ /**
63
+ * Programmatic-only model override. Never populated from an agent file: which
64
+ * model a subagent runs is the tier catalogue's decision, and `model:` in
65
+ * frontmatter is read as documentation of an unfinished migration rather than
66
+ * a pin. Left on the type for callers that construct a config in process.
67
+ */
62
68
  model?: string;
69
+ /** Programmatic-only, for the same reason as `model` above. */
63
70
  thinking?: ThinkingLevel;
64
71
  maxTurns?: number;
65
72
  /** Persist this subagent as a normal pi session instead of keeping it in memory only. */