@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+ ### Minor Changes
5
+
6
+ - 1860def: Name your model tiers, and stop the orchestrator choosing models by hand.
7
+
8
+ A tier is one name for a (model, thinking) pair, defined in `subagents.json`:
9
+
10
+ ```json
11
+ {
12
+ "agentTiers": {
13
+ "defaultTier": "medium",
14
+ "profiles": {
15
+ "small": { "description": "Fast, cheap exploration", "model": "deepseek/deepseek-v4-flash", "thinking": "max" },
16
+ "research": { "description": "Long-context research", "model": "kimi/k3", "thinking": "max" }
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ Keys are arbitrary — `small`/`medium`/`large` are an example, not a vocabulary.
23
+ A profile requires both `model` and `thinking` (either may be `"inherit"`);
24
+ `description` is optional and is what the host reads when choosing.
25
+
26
+ Resolution order: the `tier` on the call, then `tier:` in the agent's
27
+ frontmatter, then `agentTiers.defaultTier`, then the agent's legacy
28
+ `model:`/`thinking:`, then the parent session. A tier that applies decides both
29
+ fields outright, and an agent carrying both a tier and legacy pins logs a warning
30
+ naming the file. Unknown keys, malformed profiles and unavailable models refuse
31
+ the spawn before it starts, naming the tier and where it came from — none of them
32
+ quietly substitutes another model.
33
+
34
+ The catalogue is rendered into the `Agent` tool description at registration, so
35
+ the host knows the vocabulary before its first call rather than having to
36
+ remember a lookup tool. Custom descriptions gain `{{tierList}}`,
37
+ `{{compactTierList}}` and `{{defaultTier}}`.
38
+
39
+ Resolution happens once, in `agent-runner.ts`, so the top-level `Agent` tool,
40
+ nested delegation, the scheduler and cross-extension RPC share one precedence and
41
+ one set of refusals.
42
+
43
+ `pi-workflows` is untouched: its tiers stay the fixed `small | medium | large` of
44
+ the cross-package protocol, which is what keeps a workflow definition validatable
45
+ at parse time and portable between machines. The two systems share no fields.
46
+
47
+ The agent conversation overlay now marks its own edges with a rule at the top
48
+ and the bottom. It floats over the transcript and previously drew no border at
49
+ all — two variables named `hrTop` and `hrMid` were a bold title row and a blank
50
+ line — so there was no telling where the agent's conversation ended and the
51
+ parent's resumed. Two rules, none in between: an inner rule competes with the
52
+ pair that marks the boundary, and a four-sided box would cost two columns on
53
+ every row for the same job.
54
+
55
+ The LLM-facing `Agent` and nested-`Agent` schemas no longer accept `model` or
56
+ `thinking` — a caller picks a tier instead. This is a minor, not a major: a tool
57
+ schema is read by a model at runtime rather than compiled against, so no import,
58
+ settings key or RPC payload changes shape. Programmatic callers and the legacy
59
+ RPC still accept both fields, and agents with no tier configured behave exactly
60
+ as before. The migration is: define the profiles once, then replace each agent's
61
+ `model:`/`thinking:` with `tier: <name>`.
62
+
3
63
  ## 1.0.0
4
64
  ### Major Changes
5
65
 
package/README.md CHANGED
@@ -230,8 +230,9 @@ All fields are optional — sensible defaults for everything.
230
230
  | `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
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
- | `model` | inherit parent | 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 |
234
- | `thinking` | inherit | off, minimal, low, medium, high, xhigh, max actual availability depends on your pi version and model; pi clamps unsupported levels down |
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 |
235
236
  | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
236
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` |
237
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:` |
@@ -317,8 +318,7 @@ Launch a sub-agent.
317
318
  | `prompt` | string | yes | The task for the agent |
318
319
  | `description` | string | yes | Short 3-5 word summary (shown in UI) |
319
320
  | `subagent_type` | string | yes | Agent type (built-in or custom) |
320
- | `model` | string | no | Model `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp interchangeable) with provider fallback |
321
- | `thinking` | string | no | Thinking level: off, minimal, low, medium, high, xhigh, max (availability depends on pi version and model) |
321
+ | `tier` | string | no | Model tier for this spawn, by name. Overrides the agent's own default tier. Unknown tiers are rejected, not substituted see [Model tiers](#model-tiers) |
322
322
  | `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) |
323
323
  | `run_in_background` | boolean | no | Run without blocking |
324
324
  | `resume` | string | no | Agent ID to resume a previous session |
@@ -409,6 +409,104 @@ When background agents complete, they notify the main agent. The **join mode** c
409
409
  **Configuration:**
410
410
  - Configure join mode in `/agents` → Settings → Join mode
411
411
 
412
+ ## Model tiers
413
+
414
+ A **tier** is one name for a (model, thinking) pair. The host agent picks a tier
415
+ by name and nothing else: the `Agent` tool exposes `tier` and does **not** expose
416
+ `model` or `thinking`, so which model runs is decided by whoever writes
417
+ `subagents.json`, not by the orchestrator improvising per call.
418
+
419
+ Names are yours. `small`/`medium`/`large` below are only an example — `research`,
420
+ `cheap`, `nightly` are equally valid keys.
421
+
422
+ ```json
423
+ {
424
+ "agentTiers": {
425
+ "defaultTier": "medium",
426
+ "profiles": {
427
+ "small": { "description": "Fast, cheap exploration", "model": "deepseek/deepseek-v4-flash", "thinking": "max" },
428
+ "medium": { "description": "Ordinary planning and review", "model": "openai-codex/gpt-5.6-luna", "thinking": "max" },
429
+ "large": { "description": "Architecture and risky review", "model": "openai-codex/gpt-5.6-sol", "thinking": "xhigh" },
430
+ "research": { "description": "Long-context research", "model": "kimi/k3", "thinking": "max" }
431
+ }
432
+ }
433
+ }
434
+ ```
435
+
436
+ A profile is all-or-nothing: both `model` and `thinking` are required, and either
437
+ may be the literal `"inherit"` to keep the parent's. `description` is optional and
438
+ defaults to the key; it is what the host reads when choosing between tiers.
439
+
440
+ ### How the host discovers tiers
441
+
442
+ The catalogue is rendered into the `Agent` tool description at registration, so
443
+ the host knows the vocabulary before its first call — there is no lookup tool to
444
+ remember. It sees:
445
+
446
+ ```
447
+ Available agent tiers:
448
+
449
+ - small: Fast, cheap exploration
450
+ model: deepseek/deepseek-v4-flash
451
+ thinking: max
452
+ ...
453
+ Default tier: medium
454
+
455
+ The caller may pass only a tier key. Do not pass model or thinking directly.
456
+ ```
457
+
458
+ A [custom tool description](#persistent-settings) can place it with
459
+ `{{tierList}}`, `{{compactTierList}}` or `{{defaultTier}}`. Tier changes apply on
460
+ the next pi session, since the description is built once at registration.
461
+
462
+ ### Precedence
463
+
464
+ 1. `tier` passed to the `Agent` call
465
+ 2. `tier:` in the agent's frontmatter
466
+ 3. `agentTiers.defaultTier`
467
+ 4. legacy `model:`/`thinking:` in the agent's frontmatter
468
+ 5. the parent session's model and thinking
469
+
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.
473
+
474
+ ### Refusals
475
+
476
+ These fail **before** the spawn, with the tier key and where it came from named.
477
+ None of them silently substitutes another model:
478
+
479
+ - a tier key nobody defined (from the call, the agent file, or `defaultTier`)
480
+ - a profile dropped as malformed during settings load
481
+ - a profile whose model is not available on this machine
482
+ - a syntactically invalid key (blank, whitespace, over 64 characters)
483
+
484
+ ### Merging global and project settings
485
+
486
+ `~/.pi/agent/subagents.json` supplies the catalogue; `<cwd>/.pi/subagents.json`
487
+ edits it. A project profile replaces its global namesake **whole** — never field
488
+ by field, which would let a project change a model while inheriting a thinking
489
+ level nobody chose for that pair. A project profile that fails validation blocks
490
+ its global namesake rather than reviving it, and `defaultTier` is a simple
491
+ project-over-global override.
492
+
493
+ ### Not the same as `workflow.tiers`
494
+
495
+ `pi-workflows` has its own tiers, and they stay fixed at `small | medium | large`.
496
+ That vocabulary is part of the cross-package protocol: it lets a workflow
497
+ definition be validated at parse time and stay portable between machines, neither
498
+ of which survives arbitrary names. The two systems share no fields — a spawn
499
+ records `agentTier`/`agentTierSnapshot` or `tier`/`tierSnapshot`, never one
500
+ standing in for the other.
501
+
502
+ ### Migrating from `model:`/`thinking:`
503
+
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.
509
+
412
510
  ## Model Scope
413
511
 
414
512
  **Opt-in:** off by default. Enable via `/agents → Settings → Scope models`.
@@ -3,7 +3,7 @@ Launch a new agent to handle complex, multi-step tasks autonomously. Each agent
3
3
  Available agent types and the tools they have access to:
4
4
  {{typeList}}
5
5
 
6
- Custom agents can be defined in .pi/agents/<name>.md (project) or {{agentDir}}/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.
6
+ Custom agents can be defined in .pi/agents/<name>.md (project) or {{agentDir}}/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.{{tierList}}
7
7
 
8
8
  When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
9
9
 
@@ -23,8 +23,7 @@ If the target is already known, use a direct tool — `read` for a known path, `
23
23
  - Use steer_subagent to send mid-run messages to a running background agent.
24
24
  - 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.
25
25
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
26
- - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
27
- - Use thinking to control extended thinking level.
26
+ - 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.
28
27
  - Use inherit_context if the agent needs the parent conversation history.
29
28
  - 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}}
30
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-subagents",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",
@@ -15,6 +15,7 @@ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-wor
15
15
  import type { ManagedSpawnRequest as ProtocolManagedSpawnRequest, WorkflowTier } from "@signalridge/pi-subagents-protocol";
16
16
  import { isWorkflowTier, parseManagedSpawnRequest } from "@signalridge/pi-subagents-protocol";
17
17
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
18
+ import type { AgentTierResolutionSnapshot } from "./agent-tiers.js";
18
19
  import {
19
20
  INTERNAL_AGENT_CONFIG_OVERRIDE,
20
21
  type InternalAgentConfigOverride,
@@ -523,6 +524,8 @@ export interface SpawnOptions {
523
524
  thinkingLevel?: ThinkingLevel;
524
525
  /** Semantic workflow tier resolved by pi-subagents at session start. */
525
526
  tier?: WorkflowTier;
527
+ /** User-named model tier; resolved by pi-subagents at session start. */
528
+ agentTier?: string;
526
529
  isBackground?: boolean;
527
530
  /**
528
531
  * Skip the maxConcurrent queue check for this spawn — start immediately even
@@ -553,6 +556,8 @@ export interface SpawnOptions {
553
556
  onSessionCreated?: (session: AgentSession) => void;
554
557
  /** Called after pi-subagents resolves a semantic workflow tier. */
555
558
  onTierResolved?: (snapshot: WorkflowTierResolutionSnapshot) => void;
559
+ /** Called after pi-subagents resolves a user-named agent tier. */
560
+ onAgentTierResolved?: (snapshot: AgentTierResolutionSnapshot) => void;
556
561
  /** Called synchronously after a new record is allocated, before session creation. */
557
562
  onSpawned?: (id: string) => void;
558
563
  /** Called at the end of each agentic turn with the cumulative count. */
@@ -1270,6 +1275,7 @@ export class AgentManager {
1270
1275
  ...(internalOverride ? { [INTERNAL_AGENT_CONFIG_OVERRIDE]: internalOverride } : {}),
1271
1276
  thinkingLevel: options.thinkingLevel,
1272
1277
  tier: options.tier,
1278
+ agentTier: options.agentTier,
1273
1279
  // Worktree wins for the working dir (the agent must run in the copy —
1274
1280
  // which, with a custom cwd, was created from that target). Config stays
1275
1281
  // with the parent project when a caller-supplied cwd is in play; it must
@@ -1323,6 +1329,21 @@ export class AgentManager {
1323
1329
  options.onTierResolved?.(snapshot);
1324
1330
  }
1325
1331
  },
1332
+ // Recorded on the same record as the workflow snapshot but under its own
1333
+ // field, so a run can carry both without either overwriting the other's
1334
+ // account of how its model was chosen.
1335
+ onAgentTierResolved: (snapshot) => {
1336
+ if (!record.detached) {
1337
+ record.invocation = {
1338
+ ...(record.invocation ?? {}),
1339
+ agentTier: snapshot.tier,
1340
+ thinking: snapshot.thinking,
1341
+ agentTierSnapshot: { ...snapshot },
1342
+ };
1343
+ this.syncManagedRecord(record, true);
1344
+ options.onAgentTierResolved?.(snapshot);
1345
+ }
1346
+ },
1326
1347
  onSessionCreated: (session) => {
1327
1348
  if (record.detached) {
1328
1349
  this.trackRecordSessionTeardown(id, session);
@@ -19,6 +19,7 @@ import {
19
19
  SettingsManager,
20
20
  } from "@earendil-works/pi-coding-agent";
21
21
  import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
22
+ import { type AgentTierResolutionSnapshot, resolveAgentTier } from "./agent-tiers.js";
22
23
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
23
24
  import { runInChildSessionContext } from "./child-context.js";
24
25
  import { buildParentContext, extractText } from "./context.js";
@@ -380,6 +381,12 @@ export interface RunOptions {
380
381
  thinkingLevel?: ThinkingLevel;
381
382
  /** Semantic workflow tier; resolved here rather than by workflow callers. */
382
383
  tier?: WorkflowTier;
384
+ /**
385
+ * User-named model tier for an ordinary spawn. Resolved here so the top-level
386
+ * Agent tool, nested delegation, the scheduler and cross-extension RPC all get
387
+ * the same precedence and the same fail-closed errors from one place.
388
+ */
389
+ agentTier?: string;
383
390
  /** Parent thinking level used only when a tier profile omits thinking. */
384
391
  parentThinking?: ThinkingLevel;
385
392
  /** Override working directory (e.g. for worktree isolation). */
@@ -407,6 +414,8 @@ export interface RunOptions {
407
414
  onSessionCreated?: (session: AgentSession) => void;
408
415
  /** Called after pi-subagents resolves a semantic workflow tier. */
409
416
  onTierResolved?: (snapshot: WorkflowTierResolutionSnapshot) => void;
417
+ /** Called after pi-subagents resolves a user-named agent tier. */
418
+ onAgentTierResolved?: (snapshot: AgentTierResolutionSnapshot) => void;
410
419
  /** Called at the end of each agentic turn with the cumulative count. */
411
420
  onTurnEnd?: (turnCount: number) => void;
412
421
  /**
@@ -622,6 +631,29 @@ export async function runAgent(
622
631
  : undefined;
623
632
  if (tierResolution?.snapshot) options.onTierResolved?.(tierResolution.snapshot);
624
633
 
634
+ // Agent tiers resolve in the same place and before the same await, so every
635
+ // spawn path shares one precedence and one set of fail-closed errors. A tier
636
+ // that applies decides model and thinking outright: it is current policy,
637
+ // while an agent's legacy `model:`/`thinking:` frontmatter is the older, weaker
638
+ // statement of the same thing. Throwing here refuses the spawn rather than
639
+ // quietly running a model the caller did not choose.
640
+ const agentTierResolution = resolveAgentTier({
641
+ requestedTier: options.agentTier,
642
+ agentConfig,
643
+ parentModel: ctx.model,
644
+ parentThinking,
645
+ modelRegistry: ctx.modelRegistry,
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
+ }
656
+
625
657
  // Resolve working directory: worktree override > parent cwd
626
658
  const effectiveCwd = options.cwd ?? ctx.cwd;
627
659
  // Filesystem work happens in effectiveCwd; config discovery in configCwd.
@@ -842,12 +874,33 @@ export async function runAgent(
842
874
  }
843
875
  }
844
876
 
845
- const model = options.model ?? tierResolution?.model ?? resolveDefaultModel(
877
+ // `options.model` stays highest: it is a resolved Model handed over by a
878
+ // programmatic caller, which is a more explicit act than naming a tier.
879
+ const model = options.model ?? agentTierResolution.model ?? tierResolution?.model ?? resolveDefaultModel(
846
880
  ctx.model, ctx.modelRegistry, agentConfig?.model,
847
881
  );
848
- const thinkingLevel = options.tier !== undefined
849
- ? tierResolution?.thinkingLevel
850
- : options.thinkingLevel ?? agentConfig?.thinking;
882
+ const thinkingLevel = agentTierResolution.snapshot
883
+ ? agentTierResolution.thinkingLevel
884
+ : options.tier !== undefined
885
+ ? tierResolution?.thinkingLevel
886
+ : options.thinkingLevel ?? agentConfig?.thinking;
887
+
888
+ if (agentTierResolution.snapshot) {
889
+ const { configuredModel, source } = agentTierResolution.snapshot;
890
+ const scopeVerdict = checkModelScope({
891
+ model,
892
+ cwd: ctx.cwd,
893
+ modelRegistry: ctx.modelRegistry,
894
+ // A tier the caller named is a runtime choice by the model, which is what
895
+ // scopeModels exists to police; a tier that came from the agent file or
896
+ // the configured default is the user's own config and only warns.
897
+ callerSupplied: source === "call",
898
+ agentLabel: agentConfig?.displayName ?? type,
899
+ modelInput: configuredModel === "inherit" ? undefined : configuredModel,
900
+ });
901
+ if (scopeVerdict.kind === "error") throw new Error(scopeVerdict.message);
902
+ if (scopeVerdict.kind === "warn" && ctx.hasUI) ctx.ui.notify(scopeVerdict.message, "warning");
903
+ }
851
904
 
852
905
  if (options.tier) {
853
906
  const configuredModel = tierResolution?.snapshot?.configuredModel;