@signalridge/pi-subagents 1.3.0 → 1.5.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 +62 -0
- package/README.md +76 -13
- package/package.json +1 -1
- package/src/agent-file-toggle.ts +3 -2
- package/src/agent-runner.ts +49 -13
- package/src/agent-tiers.ts +147 -6
- package/src/custom-agents.ts +24 -3
- package/src/default-agents.ts +5 -4
- package/src/index.ts +322 -17
- package/src/settings.ts +49 -6
- package/src/types.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
### Minor Changes
|
|
5
|
+
|
|
6
|
+
- a6cf423: Add a settings interface for the subagent default model and tier. Both were
|
|
7
|
+
previously reachable only by hand-editing `subagents.json`.
|
|
8
|
+
|
|
9
|
+
`/agents → Settings` gains two rows. **Default model** writes a new
|
|
10
|
+
`defaultModel` key: the model a subagent runs when no tier picked one, slotting
|
|
11
|
+
in ahead of the parent session at the end of resolution, so a workspace can say
|
|
12
|
+
"subagents run on the cheap model" without first defining a tier catalogue. It
|
|
13
|
+
decides only the model — thinking still comes from the parent, since a level
|
|
14
|
+
nobody chose for a specific model is what a tier exists to express — and any
|
|
15
|
+
tier that applies overrides it. **Default tier** selects `agentTiers.defaultTier`
|
|
16
|
+
from the defined tier keys.
|
|
17
|
+
|
|
18
|
+
`/agents → Model tiers` is a new menu for the catalogue itself: create a tier,
|
|
19
|
+
change its model, thinking or description, or delete it. The model picker
|
|
20
|
+
enumerates pi's own registry (narrowed to your scope when Scope models is on)
|
|
21
|
+
plus `inherit` and a typed escape hatch for a provider this machine has not
|
|
22
|
+
authed. The thinking picker offers only the levels the chosen model reports as
|
|
23
|
+
supported, rather than a fixed list whose extra entries `clampThinkingLevel`
|
|
24
|
+
would silently lower at spawn. A tier dropped as malformed stays listed as
|
|
25
|
+
`blocked` so redefining it retires the tombstone in one step, and deleting the
|
|
26
|
+
tier that `defaultTier` names clears the default in the same write rather than
|
|
27
|
+
leaving every untiered spawn to fail.
|
|
28
|
+
|
|
29
|
+
Unlike a tier, an unresolvable `defaultModel` falls back to the parent model
|
|
30
|
+
instead of failing the spawn — a tier is refused because someone named that
|
|
31
|
+
policy at the call site, while `defaultModel` is the value nobody named, so one
|
|
32
|
+
unauthed provider must not take every spawn on the machine down with it. The
|
|
33
|
+
Settings row flags it as `(unavailable, fallback: inherit)`. `defaultModel`
|
|
34
|
+
accepts the literal `"inherit"`, which is how a project cancels a global default;
|
|
35
|
+
omitting the key still inherits whatever the global file set.
|
|
36
|
+
|
|
37
|
+
## 1.4.0
|
|
38
|
+
### Minor Changes
|
|
39
|
+
|
|
40
|
+
- 72be09a: An agent no longer picks its own model.
|
|
41
|
+
|
|
42
|
+
`model:` and `thinking:` in agent frontmatter are no longer read. Which model a
|
|
43
|
+
subagent runs is the tier catalogue's decision, and a per-file pin was a way
|
|
44
|
+
around it — silently, since nothing warned unless the file also named a tier. A
|
|
45
|
+
file that still carries them loads and runs exactly as before; the two lines
|
|
46
|
+
have no effect, and a warning names the file so the migration can be finished
|
|
47
|
+
one agent at a time.
|
|
48
|
+
|
|
49
|
+
The built-in agents drop their own pin for the same reason: `Explore` named
|
|
50
|
+
`anthropic/claude-haiku-4-5`, which is both an end-run around the catalogue and
|
|
51
|
+
a vendor the machine may not have.
|
|
52
|
+
|
|
53
|
+
The resulting fallback is simple and worth stating: with no tier passed, none in
|
|
54
|
+
the agent, and no `agentTiers.defaultTier`, a subagent runs on the parent
|
|
55
|
+
session's model.
|
|
56
|
+
|
|
57
|
+
A `defaultTier` — or an agent's `tier:` — that names no defined profile is now
|
|
58
|
+
reported at startup with the available keys, rather than waiting for the first
|
|
59
|
+
spawn that needs it. `/agents` no longer advertises a pinned model in the type
|
|
60
|
+
list and writes `tier:` instead of `model:` when it regenerates an agent file.
|
|
61
|
+
|
|
62
|
+
Programmatic callers and the legacy RPC still accept `model`/`thinking`. That is
|
|
63
|
+
an escape hatch for code, not a way to configure an agent.
|
|
64
|
+
|
|
3
65
|
## 1.3.0
|
|
4
66
|
### Minor Changes
|
|
5
67
|
|
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-
|
|
|
28
28
|
- **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
|
|
29
29
|
- **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Protocol v3 adds policy-free managed spawning plus owner-scoped stop/quiescence; workflow callers require the advertised `ownedStop` capability and never fall back to unowned stop. Standardized reply envelopes provide versioning. Emits `subagents:ready` on session start
|
|
30
30
|
- **Schedule subagents** — pass `schedule` to the `Agent` tool to fire on cron / interval / one-shot. Session-scoped jobs with PID-locked persistence; results land via the same `subagent-notification` followUp path as manual background completions; manage via `/agents → Scheduled jobs`
|
|
31
|
+
- **Model tiers** — name a (model, thinking) pair once and let the orchestrator pick it by name; the `Agent` tool exposes `tier` and never `model`/`thinking`, so which model runs stays a config decision. Manage the catalogue in `/agents → Model tiers`, pick the default in `/agents → Settings → Default tier`, or set a plain `defaultModel` when one line beats a catalogue
|
|
31
32
|
- **Model scope enforcement** — opt-in validation that subagent model choices stay within your pi `enabledModels` allowlist (sourced from `/scoped-models`, with both global and project-local pi settings honored). Caller-supplied out-of-scope → hard error to orchestrator; frontmatter-pinned out-of-scope → warning + runs anyway (frontmatter authoritative). Toggle via `/agents → Settings → Scope models`
|
|
32
33
|
- **Resilient agent files** — malformed custom `.md` files are skipped with a path-qualified warning so one bad file cannot prevent startup; enable `/agents → Settings → Strict agent files` when startup should fail closed instead
|
|
33
34
|
|
|
@@ -231,8 +232,8 @@ All fields are optional — sensible defaults for everything.
|
|
|
231
232
|
| `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
|
|
232
233
|
| `isolation` | — | Set to `worktree` to run in an isolated git worktree |
|
|
233
234
|
| `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
|
-
|
|
|
235
|
-
|
|
|
235
|
+
| ~~`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 |
|
|
236
|
+
| ~~`thinking`~~ | — | **Removed**, same as `model` above |
|
|
236
237
|
| `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
|
|
237
238
|
| `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
239
|
| `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:` |
|
|
@@ -358,6 +359,7 @@ The `/agents` command opens an interactive menu:
|
|
|
358
359
|
```
|
|
359
360
|
Agent runs (2) · 1 running · 0 queued · 1 completed · 0 wrapped up · 0 stopped · 0 aborted · 0 failed
|
|
360
361
|
Agent types (6) · unified list of defaults and custom agents
|
|
362
|
+
Model tiers (3) · the (model, thinking) catalogue
|
|
361
363
|
Create new agent
|
|
362
364
|
Settings
|
|
363
365
|
```
|
|
@@ -371,7 +373,8 @@ Settings
|
|
|
371
373
|
- **Eject** — writes the embedded default config as a `.md` file to project or personal location, so you can customize it
|
|
372
374
|
- **Disable/Enable** — toggle agent availability. Disabled agents stay visible in the list with a `disabled` label and can be re-enabled
|
|
373
375
|
- **Create new agent** — choose project/personal location, then manual wizard (step-by-step prompts for name, tools, model, thinking, system prompt) or AI-generated (describe what the agent should do; a sub-agent writes a unique same-directory staging file, and the parent parses and compare-commits it to the target). Any name is allowed, including default agent names (overrides them)
|
|
374
|
-
- **
|
|
376
|
+
- **Model tiers** — create, edit and delete the [tier](#model-tiers) profiles. Each row shows what the tier resolves to on this machine (`small — claude-haiku-4-5 · thinking max`), and a tier dropped as malformed is listed as `blocked` so it can be fixed rather than staying invisible. The model picker offers this machine's available models (narrowed to your scope when **Scope models** is on) plus `inherit` and a typed escape hatch; the thinking picker offers only the levels the chosen model actually supports, since the rest would be silently clamped at spawn. Deleting the tier that `defaultTier` names clears the default in the same step
|
|
377
|
+
- **Settings** — configure max concurrency, default max turns, grace turns, default model, default tier, and join mode at runtime
|
|
375
378
|
|
|
376
379
|
## Graceful Max Turns
|
|
377
380
|
|
|
@@ -464,15 +467,70 @@ the next pi session, since the description is built once at registration.
|
|
|
464
467
|
1. `tier` passed to the `Agent` call
|
|
465
468
|
2. `tier:` in the agent's frontmatter
|
|
466
469
|
3. `agentTiers.defaultTier`
|
|
467
|
-
4.
|
|
470
|
+
4. `defaultModel` — a model with no tier attached, for workspaces that want one
|
|
471
|
+
setting rather than a catalogue
|
|
468
472
|
5. the parent session's model and thinking
|
|
469
473
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
474
|
+
An agent cannot pin its own model at any step. `model:`/`thinking:` in
|
|
475
|
+
frontmatter are read only to warn that they are stale, and the built-in agents
|
|
476
|
+
pin nothing either. With nothing configured at all, a subagent runs on the
|
|
477
|
+
parent session's model.
|
|
478
|
+
|
|
479
|
+
### `defaultModel`
|
|
480
|
+
|
|
481
|
+
Steps 1–3 are a catalogue; step 4 is one line. Set it when the whole point is
|
|
482
|
+
"subagents run on the cheap model" and there is no second policy to name:
|
|
483
|
+
|
|
484
|
+
```json
|
|
485
|
+
{ "defaultModel": "anthropic/claude-haiku-4-5" }
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
It only decides the model — thinking still comes from the parent, because a
|
|
489
|
+
level nobody chose for a specific model is exactly what a tier exists to
|
|
490
|
+
express. Any tier that applies overrides it outright.
|
|
491
|
+
|
|
492
|
+
Set it from `/agents → Settings → Default model` (Enter opens the picker), or by
|
|
493
|
+
hand. It accepts the same references a tier's `model` does, plus the literal
|
|
494
|
+
`"inherit"`, which is how a project cancels a global `defaultModel` — omitting
|
|
495
|
+
the key inherits whatever the global file set.
|
|
496
|
+
|
|
497
|
+
Unlike a tier, an unresolvable `defaultModel` does **not** fail the spawn: it
|
|
498
|
+
falls back to the parent model, and the Settings row shows
|
|
499
|
+
`(unavailable, fallback: inherit)`. A tier is refused because someone named that
|
|
500
|
+
policy at the call site; `defaultModel` is the value nobody named, so one
|
|
501
|
+
unauthed provider must not take every spawn on the machine down with it.
|
|
502
|
+
|
|
503
|
+
### Editing tiers
|
|
504
|
+
|
|
505
|
+
`/agents → Model tiers` manages the catalogue — new tier, change a tier's model,
|
|
506
|
+
thinking or description, delete one. `defaultTier` lives with the other defaults
|
|
507
|
+
in `/agents → Settings → Default tier`. Both write the project file; the global
|
|
508
|
+
file is never written from the menu.
|
|
509
|
+
|
|
510
|
+
Two things the menu knows that a hand-edited file does not: the thinking picker
|
|
511
|
+
offers only levels the chosen model supports (the rest get clamped at spawn
|
|
512
|
+
anyway), and a tier dropped as malformed still appears in the list, marked
|
|
513
|
+
`blocked`, so redefining it is one selection rather than an archaeology
|
|
514
|
+
expedition through `subagents.json`.
|
|
515
|
+
|
|
516
|
+
The `Agent` tool description is built once at registration, so a tier edit
|
|
517
|
+
reaches the model on the next pi session. Resolution itself is live — a spawn
|
|
518
|
+
right after the edit already uses the new profile.
|
|
519
|
+
|
|
520
|
+
One thing the file format cannot express: a project *deleting* a tier that only
|
|
521
|
+
the global file defines. The menu writes the merged catalogue back to the
|
|
522
|
+
project file, so deleting one of several works, but deleting the last one — or
|
|
523
|
+
clearing a `defaultTier` that only global sets — leaves no `agentTiers` key
|
|
524
|
+
behind, and the global value is inherited again on the next start. Remove it
|
|
525
|
+
from `~/.pi/agent/subagents.json` instead. The same is true of `workflow.tiers`.
|
|
473
526
|
|
|
474
527
|
### Refusals
|
|
475
528
|
|
|
529
|
+
A `defaultTier`, or an agent's `tier:`, that names no defined profile is
|
|
530
|
+
reported at **startup**, listing the available keys — a typo there would
|
|
531
|
+
otherwise sit quiet until the first spawn that needed it, possibly minutes into
|
|
532
|
+
a session.
|
|
533
|
+
|
|
476
534
|
These fail **before** the spawn, with the tier key and where it came from named.
|
|
477
535
|
None of them silently substitutes another model:
|
|
478
536
|
|
|
@@ -501,11 +559,14 @@ standing in for the other.
|
|
|
501
559
|
|
|
502
560
|
### Migrating from `model:`/`thinking:`
|
|
503
561
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
562
|
+
Define the profiles once, then replace each agent's `model:`/`thinking:` with
|
|
563
|
+
`tier: <name>`. Files that still carry the old fields load and run — the fields
|
|
564
|
+
are ignored, with a warning naming the file — so the migration can be done one
|
|
565
|
+
agent at a time. Until an agent names a tier it uses `defaultTier`, or the
|
|
566
|
+
parent's model when none is set.
|
|
567
|
+
|
|
568
|
+
Programmatic callers and the legacy RPC may still pass `model`/`thinking`
|
|
569
|
+
directly. That is the escape hatch for code, not a way to configure an agent.
|
|
509
570
|
|
|
510
571
|
## Model Scope
|
|
511
572
|
|
|
@@ -531,7 +592,7 @@ When on, each subagent spawn's effective model is validated against pi's own `en
|
|
|
531
592
|
|
|
532
593
|
## Persistent Settings
|
|
533
594
|
|
|
534
|
-
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, nested depth, fallback agent, default join mode, scheduling on/off, scope models on/off, strict agent files on/off, disable defaults on/off, output transcript on/off, tool description full/compact/custom, fleet view on/off) persist across pi restarts. Two files, merged on load:
|
|
595
|
+
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, nested depth, fallback agent, default model, default tier, default join mode, scheduling on/off, scope models on/off, strict agent files on/off, disable defaults on/off, output transcript on/off, tool description full/compact/custom, fleet view on/off) persist across pi restarts. Two files, merged on load:
|
|
535
596
|
|
|
536
597
|
- **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
|
|
537
598
|
- **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
|
|
@@ -555,6 +616,8 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, default m
|
|
|
555
616
|
|
|
556
617
|
Profiles are complete `model` + `thinking` tuples. Each field may use `inherit`; a project profile replaces the whole matching global tier entry. Malformed or incomplete entries are retained as durable blocked-tier tombstones and fail closed rather than falling back to a built-in profile or merging field-by-field. An explicit `defaultTier` applies when a workflow task omits its tier. Without a task tier or configured default, the parent model and thinking level are inherited. Agent frontmatter remains authoritative for its explicit `model` and `thinking`; thinking is clamped to the selected model's native supported levels.
|
|
557
618
|
|
|
619
|
+
**Default model** (`defaultModel`, unset): the model a subagent runs when no tier picked one — see [`defaultModel`](#defaultmodel) for where it sits in precedence, why an unresolvable value falls back instead of failing, and how `"inherit"` lets a project cancel a global default. **Default tier** (`agentTiers.defaultTier`, unset) is the tier applied when neither the caller nor the agent names one; the profiles it selects from live under [`agentTiers`](#model-tiers).
|
|
620
|
+
|
|
558
621
|
**Strict agent files** (`strictAgentFiles`, default `false`): normal startup skips unreadable or malformed agent definitions with a warning that includes the file path. Enable it to fail closed during the first `session_start`, using that session's `ctx.cwd`, with the path in the error instead of silently running a surviving lower-priority override. A failed validation leaves no root manager or RPC responder behind. Reloads after startup remain lenient, so an accidental edit cannot terminate an active session; the setting applies on the next pi session.
|
|
559
622
|
|
|
560
623
|
**Nested depth** (`maxSubagentDepth`, default `2`): the hard ceiling on [nested delegation](#nested-subagents), counted from the main session (main = 0, its subagents = 1). `0` or `1` disables nesting project-wide regardless of any agent's `allowed_subagents`. Read when a subagent session is built, so a change applies to agents started after it.
|
package/package.json
CHANGED
package/src/agent-file-toggle.ts
CHANGED
|
@@ -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
|
-
|
|
695
|
-
|
|
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)}`);
|
package/src/agent-runner.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type InternalAgentConfigOverride,
|
|
31
31
|
} from "./internal-run.js";
|
|
32
32
|
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
33
|
+
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
33
34
|
import { checkModelScope } from "./model-scope.js";
|
|
34
35
|
import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
|
|
35
36
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
@@ -331,13 +332,56 @@ export function getGraceTurns(): number { return graceTurns; }
|
|
|
331
332
|
/** Set the grace turns value (minimum 1). */
|
|
332
333
|
export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
|
|
333
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Model every subagent falls back to when nothing else picked one.
|
|
337
|
+
*
|
|
338
|
+
* Held here rather than in settings.ts because this is the one module that
|
|
339
|
+
* consumes it, and every spawn path already reaches model resolution through
|
|
340
|
+
* `runAgent`.
|
|
341
|
+
*
|
|
342
|
+
* Stored verbatim, `"inherit"` included, rather than normalized to `undefined`:
|
|
343
|
+
* the two are the same at spawn time but not on disk. A project that must
|
|
344
|
+
* cancel a global `defaultModel` has to write `"inherit"` into its own settings
|
|
345
|
+
* file, and a state that had already collapsed it to `undefined` would persist
|
|
346
|
+
* as an absent key and let the global value win again on the next start.
|
|
347
|
+
*/
|
|
348
|
+
let defaultModel: string | undefined;
|
|
349
|
+
|
|
350
|
+
/** The configured fallback model reference — a `provider/model`, `"inherit"`, or unset. */
|
|
351
|
+
export function getDefaultModel(): string | undefined { return defaultModel; }
|
|
352
|
+
/** Set the fallback model reference. `undefined` and blank clear it; `"inherit"` is kept. */
|
|
353
|
+
export function setDefaultModel(ref: string | undefined): void {
|
|
354
|
+
const trimmed = ref?.trim();
|
|
355
|
+
defaultModel = trimmed ? trimmed : undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The configured default model, resolved against this machine's registry.
|
|
360
|
+
*
|
|
361
|
+
* Resolved with the same fuzzy `resolveModel` the tiers use, so a hand-written
|
|
362
|
+
* `subagents.json` may name a model the way a person would. Unlike a tier it
|
|
363
|
+
* never throws: it is the value nobody chose at the call site, so an
|
|
364
|
+
* unavailable one yields to the parent rather than taking every spawn on this
|
|
365
|
+
* machine down with it.
|
|
366
|
+
*
|
|
367
|
+
* Exported because the callers that compute a spawn's model for the scope check
|
|
368
|
+
* and the UI label have to see the same answer this module will act on.
|
|
369
|
+
*/
|
|
370
|
+
export function resolveConfiguredDefaultModel(
|
|
371
|
+
registry: ModelRegistry<Model<any>>,
|
|
372
|
+
): Model<any> | undefined {
|
|
373
|
+
if (!defaultModel || defaultModel === "inherit") return undefined;
|
|
374
|
+
const resolved = resolveModel(defaultModel, registry);
|
|
375
|
+
return typeof resolved === "string" ? undefined : resolved;
|
|
376
|
+
}
|
|
377
|
+
|
|
334
378
|
/**
|
|
335
379
|
* Try to find the right model for an agent type.
|
|
336
|
-
* Priority: explicit option > config.model > parent model.
|
|
380
|
+
* Priority: explicit option > config.model > configured default model > parent model.
|
|
337
381
|
*/
|
|
338
382
|
function resolveDefaultModel(
|
|
339
383
|
parentModel: Model<any> | undefined,
|
|
340
|
-
registry:
|
|
384
|
+
registry: ModelRegistry<Model<any>>,
|
|
341
385
|
configModel?: string,
|
|
342
386
|
): Model<any> | undefined {
|
|
343
387
|
if (configModel) {
|
|
@@ -349,7 +393,7 @@ function resolveDefaultModel(
|
|
|
349
393
|
// Build a set of available model keys for fast lookup
|
|
350
394
|
const available = registry.getAvailable?.();
|
|
351
395
|
const availableKeys = available
|
|
352
|
-
? new Set(available.map((m
|
|
396
|
+
? new Set(available.map((m) => `${m.provider}/${m.id}`))
|
|
353
397
|
: undefined;
|
|
354
398
|
const isAvailable = (p: string, id: string) =>
|
|
355
399
|
!availableKeys || availableKeys.has(`${p}/${id}`);
|
|
@@ -359,7 +403,7 @@ function resolveDefaultModel(
|
|
|
359
403
|
}
|
|
360
404
|
}
|
|
361
405
|
|
|
362
|
-
return parentModel;
|
|
406
|
+
return resolveConfiguredDefaultModel(registry) ?? parentModel;
|
|
363
407
|
}
|
|
364
408
|
|
|
365
409
|
/** Info about a tool event in the subagent. */
|
|
@@ -644,15 +688,7 @@ export async function runAgent(
|
|
|
644
688
|
parentThinking,
|
|
645
689
|
modelRegistry: ctx.modelRegistry,
|
|
646
690
|
});
|
|
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
|
-
}
|
|
691
|
+
if (agentTierResolution.snapshot) options.onAgentTierResolved?.(agentTierResolution.snapshot);
|
|
656
692
|
|
|
657
693
|
// Resolve working directory: worktree override > parent cwd
|
|
658
694
|
const effectiveCwd = options.cwd ?? ctx.cwd;
|
package/src/agent-tiers.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
|
|
27
27
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
28
|
-
import type
|
|
28
|
+
import { type AgentTierProfile, type AgentTiersSettings, TIER_THINKING_LEVELS, type TierThinking } from "./settings.js";
|
|
29
29
|
import type { AgentConfig, ThinkingLevel } from "./types.js";
|
|
30
30
|
|
|
31
31
|
/** `provider/id`, or undefined when no model was selected. */
|
|
@@ -111,15 +111,117 @@ export class AgentTierError extends Error {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
/** Every defined tier key, sorted — the catalogue as the UI and the host see it. */
|
|
115
|
+
export function listAgentTierKeys(settings: AgentTiersSettings): string[] {
|
|
115
116
|
return Object.keys(settings.profiles ?? {}).sort((a, b) => a.localeCompare(b));
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
function tierKeyList(settings: AgentTiersSettings): string {
|
|
119
|
-
const keys =
|
|
120
|
+
const keys = listAgentTierKeys(settings);
|
|
120
121
|
return keys.length > 0 ? keys.join(", ") : "(none configured)";
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Tier edits, as pure settings-to-settings functions.
|
|
126
|
+
*
|
|
127
|
+
* The `/agents → Model tiers` menu is the only caller, but the rules it has to
|
|
128
|
+
* obey are policy, not presentation — retiring a tombstone, not leaving
|
|
129
|
+
* `defaultTier` pointing at a tier that no longer exists — so they live here
|
|
130
|
+
* with the resolver that enforces the other half of the same invariants.
|
|
131
|
+
*
|
|
132
|
+
* Each returns a fresh object and omits empty containers, so a catalogue edited
|
|
133
|
+
* back down to nothing serializes as nothing rather than as empty braces.
|
|
134
|
+
*/
|
|
135
|
+
function withoutBlocked(blocked: string[] | undefined, key: string): string[] | undefined {
|
|
136
|
+
const rest = (blocked ?? []).filter((k) => k !== key);
|
|
137
|
+
return rest.length > 0 ? rest : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function compactTierSettings(settings: AgentTiersSettings): AgentTiersSettings {
|
|
141
|
+
const out: AgentTiersSettings = {};
|
|
142
|
+
if (settings.defaultTier !== undefined) out.defaultTier = settings.defaultTier;
|
|
143
|
+
if (settings.profiles && Object.keys(settings.profiles).length > 0) out.profiles = settings.profiles;
|
|
144
|
+
if (settings.blockedProfiles && settings.blockedProfiles.length > 0) {
|
|
145
|
+
out.blockedProfiles = settings.blockedProfiles;
|
|
146
|
+
}
|
|
147
|
+
if (settings.blockedDefaultTier) out.blockedDefaultTier = true;
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Define or replace one tier.
|
|
153
|
+
*
|
|
154
|
+
* Writing a valid profile retires that key's tombstone: the tombstone exists to
|
|
155
|
+
* stop a malformed entry from silently resolving to some other model, and an
|
|
156
|
+
* explicit definition is the fix it was waiting for.
|
|
157
|
+
*/
|
|
158
|
+
export function upsertAgentTierProfile(
|
|
159
|
+
settings: AgentTiersSettings,
|
|
160
|
+
key: string,
|
|
161
|
+
profile: AgentTierProfile,
|
|
162
|
+
): AgentTiersSettings {
|
|
163
|
+
return compactTierSettings({
|
|
164
|
+
...settings,
|
|
165
|
+
profiles: { ...settings.profiles, [key]: profile },
|
|
166
|
+
blockedProfiles: withoutBlocked(settings.blockedProfiles, key),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Delete one tier.
|
|
172
|
+
*
|
|
173
|
+
* A `defaultTier` pointing at it is cleared in the same step. Leaving it would
|
|
174
|
+
* turn every later spawn that names no tier into a hard refusal, which is a
|
|
175
|
+
* strange thing to get from deleting a tier you had stopped using.
|
|
176
|
+
*/
|
|
177
|
+
export function removeAgentTierProfile(settings: AgentTiersSettings, key: string): AgentTiersSettings {
|
|
178
|
+
const { [key]: _removed, ...profiles } = settings.profiles ?? {};
|
|
179
|
+
return compactTierSettings({
|
|
180
|
+
...settings,
|
|
181
|
+
profiles,
|
|
182
|
+
blockedProfiles: withoutBlocked(settings.blockedProfiles, key),
|
|
183
|
+
...(settings.defaultTier === key ? { defaultTier: undefined } : {}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The thinking values a tier may usefully store for one model reference.
|
|
189
|
+
*
|
|
190
|
+
* Asked of the model rather than read off a fixed list, because `resolveAgentTier`
|
|
191
|
+
* clamps an unsupported level at spawn time: a menu offering a level destined to
|
|
192
|
+
* be silently lowered would be a menu that lies. `inherit` is always offerable —
|
|
193
|
+
* it defers to the parent session, which this model has no say over.
|
|
194
|
+
*
|
|
195
|
+
* A reference of `inherit`, or one this machine cannot resolve, yields the full
|
|
196
|
+
* static list: the model is not knowable here, and refusing to let the user
|
|
197
|
+
* configure a tier for a provider they have not authed yet would make the menu
|
|
198
|
+
* weaker than hand-editing the file.
|
|
199
|
+
*/
|
|
200
|
+
export function offerableTierThinking(
|
|
201
|
+
modelRef: string,
|
|
202
|
+
registry: ModelRegistry<Model<Api>>,
|
|
203
|
+
): TierThinking[] {
|
|
204
|
+
if (modelRef === "inherit") return [...TIER_THINKING_LEVELS];
|
|
205
|
+
const resolved = resolveModel(modelRef, registry);
|
|
206
|
+
if (typeof resolved === "string") return [...TIER_THINKING_LEVELS];
|
|
207
|
+
const supported = new Set<string>(getSupportedThinkingLevels(resolved));
|
|
208
|
+
return TIER_THINKING_LEVELS.filter(level => level === "inherit" || supported.has(level));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Set or clear the default tier.
|
|
213
|
+
*
|
|
214
|
+
* Always clears `blockedDefaultTier`: that tombstone describes the malformed
|
|
215
|
+
* value this call is replacing, and keeping it would make the resolver refuse
|
|
216
|
+
* the choice the user just made explicitly.
|
|
217
|
+
*/
|
|
218
|
+
export function setDefaultAgentTier(
|
|
219
|
+
settings: AgentTiersSettings,
|
|
220
|
+
key: string | undefined,
|
|
221
|
+
): AgentTiersSettings {
|
|
222
|
+
return compactTierSettings({ ...settings, defaultTier: key, blockedDefaultTier: false });
|
|
223
|
+
}
|
|
224
|
+
|
|
123
225
|
/**
|
|
124
226
|
* Which tier applies, and where it came from.
|
|
125
227
|
*
|
|
@@ -237,6 +339,45 @@ export function resolveAgentTier(input: ResolveAgentTierInput): AgentTierResolut
|
|
|
237
339
|
return { model, thinkingLevel, snapshot };
|
|
238
340
|
}
|
|
239
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Every tier name that is referenced but not defined.
|
|
344
|
+
*
|
|
345
|
+
* The resolver refuses these at spawn time anyway, but a `defaultTier` typo
|
|
346
|
+
* would otherwise sit quiet until the first agent that names no tier — which
|
|
347
|
+
* may be minutes into a session, in the middle of something. Checking the
|
|
348
|
+
* references once, when settings and agents are loaded, moves that discovery to
|
|
349
|
+
* where it is cheap and where the fix is obvious.
|
|
350
|
+
*
|
|
351
|
+
* `agentNames` maps an agent to the tier its frontmatter asks for, so a typo in
|
|
352
|
+
* one agent file is reported the same way as one in `defaultTier`.
|
|
353
|
+
*/
|
|
354
|
+
export function findUnknownAgentTierReferences(
|
|
355
|
+
settings: AgentTiersSettings,
|
|
356
|
+
agentTiers: ReadonlyMap<string, string> = new Map(),
|
|
357
|
+
): string[] {
|
|
358
|
+
const defined = new Set(Object.keys(settings.profiles ?? {}));
|
|
359
|
+
// With nothing configured there is no catalogue to be wrong about; the
|
|
360
|
+
// resolver simply never applies a tier.
|
|
361
|
+
if (defined.size === 0 && settings.defaultTier === undefined) return [];
|
|
362
|
+
|
|
363
|
+
const problems: string[] = [];
|
|
364
|
+
if (settings.defaultTier !== undefined && !defined.has(settings.defaultTier)) {
|
|
365
|
+
problems.push(
|
|
366
|
+
`agentTiers.defaultTier is "${settings.defaultTier}", which is not a defined tier. ` +
|
|
367
|
+
`Available: ${tierKeyList(settings)}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
for (const [agent, tier] of [...agentTiers].sort(([a], [b]) => a.localeCompare(b))) {
|
|
371
|
+
if (!defined.has(tier)) {
|
|
372
|
+
problems.push(
|
|
373
|
+
`Agent "${agent}" asks for tier "${tier}", which is not a defined tier. ` +
|
|
374
|
+
`Available: ${tierKeyList(settings)}`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return problems;
|
|
379
|
+
}
|
|
380
|
+
|
|
240
381
|
/**
|
|
241
382
|
* The tier catalogue, rendered for the `Agent` tool description.
|
|
242
383
|
*
|
|
@@ -246,7 +387,7 @@ export function resolveAgentTier(input: ResolveAgentTierInput): AgentTierResolut
|
|
|
246
387
|
* models and thinking levels appear — nothing here reads credentials.
|
|
247
388
|
*/
|
|
248
389
|
export function buildAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
249
|
-
const keys =
|
|
390
|
+
const keys = listAgentTierKeys(settings);
|
|
250
391
|
if (keys.length === 0) return "";
|
|
251
392
|
|
|
252
393
|
const entries = keys.map((key) => {
|
|
@@ -265,7 +406,7 @@ export function buildAgentTierListText(settings: AgentTiersSettings = agentTiers
|
|
|
265
406
|
|
|
266
407
|
/** One line per tier, for the compact tool description. */
|
|
267
408
|
export function buildCompactAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
268
|
-
const keys =
|
|
409
|
+
const keys = listAgentTierKeys(settings);
|
|
269
410
|
if (keys.length === 0) return "";
|
|
270
411
|
|
|
271
412
|
const entries = keys.map((key) => {
|
|
@@ -284,7 +425,7 @@ export function getDefaultAgentTierText(settings: AgentTiersSettings = agentTier
|
|
|
284
425
|
|
|
285
426
|
/** Description for the `tier` parameter, naming the keys this workspace defines. */
|
|
286
427
|
export function buildAgentTierParameterDescription(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
287
|
-
const keys =
|
|
428
|
+
const keys = listAgentTierKeys(settings);
|
|
288
429
|
const available = keys.length > 0 ? keys.join(", ") : "none configured";
|
|
289
430
|
const fallback =
|
|
290
431
|
settings.defaultTier !== undefined
|
package/src/custom-agents.ts
CHANGED
|
@@ -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
|
|
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`,
|
package/src/default-agents.ts
CHANGED
|
@@ -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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
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
|
@@ -26,14 +26,22 @@ import {
|
|
|
26
26
|
type ManagedSpawnResult,
|
|
27
27
|
type ManagedSpawnTombstone,
|
|
28
28
|
} from "./agent-manager.js";
|
|
29
|
-
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
29
|
+
import { getAgentConversation, getDefaultMaxTurns, getDefaultModel, getGraceTurns, normalizeMaxTurns, resolveConfiguredDefaultModel, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setDefaultModel, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
30
30
|
import {
|
|
31
31
|
buildAgentTierListText,
|
|
32
32
|
buildAgentTierParameterDescription,
|
|
33
33
|
buildCompactAgentTierListText,
|
|
34
|
+
findUnknownAgentTierReferences,
|
|
34
35
|
getAgentTiersSettings,
|
|
35
36
|
getDefaultAgentTierText,
|
|
37
|
+
isValidAgentTierKey,
|
|
38
|
+
listAgentTierKeys,
|
|
39
|
+
MAX_AGENT_TIER_KEY_LENGTH,
|
|
40
|
+
offerableTierThinking,
|
|
41
|
+
removeAgentTierProfile,
|
|
36
42
|
setAgentTiersSettings,
|
|
43
|
+
setDefaultAgentTier,
|
|
44
|
+
upsertAgentTierProfile,
|
|
37
45
|
} from "./agent-tiers.js";
|
|
38
46
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
39
47
|
import { inChildSessionContext } from "./child-context.js";
|
|
@@ -55,7 +63,7 @@ import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
|
|
|
55
63
|
import { createOutputFilePath, getOutputTranscriptDefault, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
56
64
|
import { SubagentScheduler } from "./schedule.js";
|
|
57
65
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
58
|
-
import { applySettings, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
66
|
+
import { type AgentTierProfile, applySettings, isModelReference, loadSettings, type SubagentsSettings, saveAndEmitChanged, type TierThinking, type ToolDescriptionMode } from "./settings.js";
|
|
59
67
|
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
|
|
60
68
|
import { type AgentConfig, type AgentInvocation, type AgentOwner, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
|
|
61
69
|
import {
|
|
@@ -1014,7 +1022,7 @@ function activateRootRuntime(
|
|
|
1014
1022
|
// workflow tier is active. Tiered runs validate their effective policy inside
|
|
1015
1023
|
// runAgent after the tier model has been resolved.
|
|
1016
1024
|
if (effectiveTier === undefined) {
|
|
1017
|
-
let model = ctxRef.model;
|
|
1025
|
+
let model = resolveConfiguredDefaultModel(ctxRef.modelRegistry) ?? ctxRef.model;
|
|
1018
1026
|
if (resolvedConfig.modelInput) {
|
|
1019
1027
|
const resolved = resolveModel(resolvedConfig.modelInput, ctxRef.modelRegistry);
|
|
1020
1028
|
if (typeof resolved === "object") model = resolved;
|
|
@@ -1191,7 +1199,7 @@ function activateRootRuntime(
|
|
|
1191
1199
|
|
|
1192
1200
|
return available.map((name) => {
|
|
1193
1201
|
const cfg = getAgentConfig(name);
|
|
1194
|
-
const modelSuffix = cfg?.
|
|
1202
|
+
const modelSuffix = cfg?.agentTier ? ` (tier: ${cfg.agentTier})` : "";
|
|
1195
1203
|
const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
|
|
1196
1204
|
return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
|
|
1197
1205
|
}).join("\n");
|
|
@@ -1225,6 +1233,7 @@ function activateRootRuntime(
|
|
|
1225
1233
|
setDefaultMaxTurns,
|
|
1226
1234
|
setGraceTurns,
|
|
1227
1235
|
setDefaultJoinMode,
|
|
1236
|
+
setDefaultModel,
|
|
1228
1237
|
setSchedulingEnabled,
|
|
1229
1238
|
setScopeModels: setScopeModelsEnabled,
|
|
1230
1239
|
setStrictAgentFiles: (enabled) => { strictAgentFiles = enabled; },
|
|
@@ -1239,6 +1248,21 @@ function activateRootRuntime(
|
|
|
1239
1248
|
});
|
|
1240
1249
|
pi.events.emit("subagents:settings_loaded", { settings: startupSettings });
|
|
1241
1250
|
|
|
1251
|
+
// A tier that nothing defines is a typo, and the resolver would only reach it
|
|
1252
|
+
// on the first spawn that needs it — possibly minutes in, mid-task. Report it
|
|
1253
|
+
// now, while the fix is obvious and nothing has run.
|
|
1254
|
+
const tierReferenceProblems = findUnknownAgentTierReferences(
|
|
1255
|
+
getAgentTiersSettings(),
|
|
1256
|
+
new Map(
|
|
1257
|
+
getAvailableTypes()
|
|
1258
|
+
.map((name): [string, string | undefined] => [name, getAgentConfig(name)?.agentTier])
|
|
1259
|
+
.filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
1260
|
+
),
|
|
1261
|
+
);
|
|
1262
|
+
for (const problem of tierReferenceProblems) {
|
|
1263
|
+
console.warn(`[pi-subagents] ${problem}`);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1242
1266
|
// ---- Agent tool ----
|
|
1243
1267
|
|
|
1244
1268
|
// Schedule param + its guideline are gated on `schedulingEnabled` (read once
|
|
@@ -1593,12 +1617,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1593
1617
|
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
|
|
1594
1618
|
|
|
1595
1619
|
// Resolve model from agent config first; tool-call params only fill gaps.
|
|
1596
|
-
|
|
1620
|
+
// With neither, runAgent falls to the configured `defaultModel` before the
|
|
1621
|
+
// parent, so mirror that here — the scope check below and the model label
|
|
1622
|
+
// must describe the model that will actually run.
|
|
1623
|
+
let model = resolveConfiguredDefaultModel(ctx.modelRegistry) ?? ctx.model;
|
|
1597
1624
|
if (resolvedConfig.modelInput) {
|
|
1598
1625
|
const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
|
|
1599
1626
|
if (typeof resolved === "string") {
|
|
1600
1627
|
if (resolvedConfig.modelFromParams) return textResult(resolved);
|
|
1601
|
-
// config-specified: silent fallback to parent
|
|
1628
|
+
// config-specified: silent fallback to the default model, then parent
|
|
1602
1629
|
} else {
|
|
1603
1630
|
model = resolved;
|
|
1604
1631
|
}
|
|
@@ -2079,24 +2106,34 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2079
2106
|
// Directory resolution and frontmatter edits live in agent-file-toggle.ts so
|
|
2080
2107
|
// they can be tested independently of this command handler.
|
|
2081
2108
|
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2109
|
+
/**
|
|
2110
|
+
* Render one configured model reference for a menu row.
|
|
2111
|
+
*
|
|
2112
|
+
* Shared by the agent list, the `Default model` setting and the tier editor,
|
|
2113
|
+
* because all three show a value someone typed into a settings file and all
|
|
2114
|
+
* three have to answer the same question about it: does this machine actually
|
|
2115
|
+
* have it? A reference that doesn't resolve falls back at runtime, so the
|
|
2116
|
+
* label says so rather than showing config that isn't in force.
|
|
2117
|
+
*/
|
|
2118
|
+
function describeModelReference(ref: string | undefined, registry?: ModelRegistry): string {
|
|
2119
|
+
if (!ref || ref === "inherit") return "inherit"; // no model configured → really inherits parent
|
|
2120
|
+
const label = getModelLabelFromConfig(ref);
|
|
2086
2121
|
if (!registry) return label;
|
|
2087
|
-
const resolved = resolveModel(
|
|
2088
|
-
// Configured but unresolvable: the runtime silently falls back to the parent
|
|
2089
|
-
// model, so flag it (and the fallback) rather than hiding the config.
|
|
2122
|
+
const resolved = resolveModel(ref, registry);
|
|
2090
2123
|
if (typeof resolved === "string") return `${label} (unavailable, fallback: inherit)`;
|
|
2091
2124
|
// Surface what it actually resolved to when that differs from the config —
|
|
2092
2125
|
// e.g. a provider fallback or a looser version pin. Cosmetic separator/date
|
|
2093
2126
|
// differences are normalized away so an effectively-identical match stays quiet.
|
|
2094
2127
|
const resolvedFull = `${resolved.provider}/${resolved.id}`;
|
|
2095
2128
|
const norm = (s: string) => s.toLowerCase().replace(/\./g, "-").replace(/-\d{8}$/, "");
|
|
2096
|
-
if (norm(
|
|
2129
|
+
if (norm(ref) === norm(resolvedFull)) return label;
|
|
2097
2130
|
return `${label} (resolved: ${resolvedFull.replace(/-\d{8}$/, "")})`;
|
|
2098
2131
|
}
|
|
2099
2132
|
|
|
2133
|
+
function getModelLabel(type: string, registry?: ModelRegistry): string {
|
|
2134
|
+
return describeModelReference(getAgentConfig(type)?.model, registry);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2100
2137
|
async function showAgentsMenu(ctx: ExtensionCommandContext) {
|
|
2101
2138
|
reloadCustomAgents();
|
|
2102
2139
|
const allNames = getAllTypes();
|
|
@@ -2119,6 +2156,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2119
2156
|
options.push(`Scheduled jobs (${jobCount})`);
|
|
2120
2157
|
}
|
|
2121
2158
|
|
|
2159
|
+
// Model tiers entry — always present, since an empty catalogue is exactly
|
|
2160
|
+
// the state where the user needs the way in to create the first tier.
|
|
2161
|
+
const tierCount = listAgentTierKeys(getAgentTiersSettings()).length;
|
|
2162
|
+
options.push(`Model tiers (${tierCount})`);
|
|
2163
|
+
|
|
2122
2164
|
// Actions
|
|
2123
2165
|
options.push("Create new agent");
|
|
2124
2166
|
options.push("Settings");
|
|
@@ -2145,6 +2187,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2145
2187
|
} else if (choice.startsWith("Scheduled jobs (")) {
|
|
2146
2188
|
await showSchedulesMenu(ctx, scheduler);
|
|
2147
2189
|
await showAgentsMenu(ctx);
|
|
2190
|
+
} else if (choice.startsWith("Model tiers (")) {
|
|
2191
|
+
await showModelTiersMenu(ctx);
|
|
2192
|
+
await showAgentsMenu(ctx);
|
|
2148
2193
|
} else if (choice === "Create new agent") {
|
|
2149
2194
|
await showCreateWizard(ctx);
|
|
2150
2195
|
} else if (choice === "Settings") {
|
|
@@ -2767,6 +2812,9 @@ Do not wrap the response in a markdown code fence. Return only the file contents
|
|
|
2767
2812
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
2768
2813
|
graceTurns: getGraceTurns(),
|
|
2769
2814
|
defaultJoinMode: getDefaultJoinMode(),
|
|
2815
|
+
// `"inherit"` is written out verbatim so a project can cancel a global
|
|
2816
|
+
// default; never configured stays undefined, which JSON.stringify drops.
|
|
2817
|
+
defaultModel: getDefaultModel(),
|
|
2770
2818
|
schedulingEnabled: isSchedulingEnabled(),
|
|
2771
2819
|
scopeModels: isScopeModelsEnabled(),
|
|
2772
2820
|
strictAgentFiles,
|
|
@@ -2808,6 +2856,69 @@ Do not wrap the response in a markdown code fence. Return only the file contents
|
|
|
2808
2856
|
void _settingsSnapshotIsComplete;
|
|
2809
2857
|
|
|
2810
2858
|
const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns", "maxSubagentDepth"]);
|
|
2859
|
+
/**
|
|
2860
|
+
* Settings whose value is chosen in a dialog rather than cycled in place.
|
|
2861
|
+
* Enter closes the list and reopens it once the dialog resolves, the same way
|
|
2862
|
+
* the numeric fields hand off to a text prompt.
|
|
2863
|
+
*/
|
|
2864
|
+
const PICKER_IDS = new Set(["defaultModel"]);
|
|
2865
|
+
/** Row value standing in for "no default tier configured". Not a tier key — keys reject whitespace, not words. */
|
|
2866
|
+
const NO_DEFAULT_TIER = "none";
|
|
2867
|
+
/** Menu entry that starts a new tier instead of editing an existing one. */
|
|
2868
|
+
const NEW_TIER_ENTRY = "+ New tier...";
|
|
2869
|
+
|
|
2870
|
+
/**
|
|
2871
|
+
* Ask for a model reference: this machine's models, plus `inherit` and a
|
|
2872
|
+
* typed escape hatch.
|
|
2873
|
+
*
|
|
2874
|
+
* The escape hatch is not decoration — the settings file accepts references
|
|
2875
|
+
* this machine cannot resolve, which is exactly what a shared project config
|
|
2876
|
+
* naming a provider only some teammates have authed looks like. A picker able
|
|
2877
|
+
* to express only what is available here would be weaker than the file it
|
|
2878
|
+
* writes.
|
|
2879
|
+
*
|
|
2880
|
+
* With `scopeModels` on and a scope configured, the list narrows to that
|
|
2881
|
+
* scope: offering a model the same setting would warn about on every spawn is
|
|
2882
|
+
* a menu arguing with itself.
|
|
2883
|
+
*/
|
|
2884
|
+
async function pickModelReference(
|
|
2885
|
+
ctx: ExtensionCommandContext,
|
|
2886
|
+
title: string,
|
|
2887
|
+
current: string | undefined,
|
|
2888
|
+
): Promise<string | undefined> {
|
|
2889
|
+
const CUSTOM = "custom...";
|
|
2890
|
+
const candidates = isScopeModelsEnabled() && ctx.scopedModels.length > 0
|
|
2891
|
+
? ctx.scopedModels.map(scoped => scoped.model)
|
|
2892
|
+
: ctx.modelRegistry.getAvailable();
|
|
2893
|
+
const refs = [...new Set(candidates.map(m => `${m.provider}/${m.id}`))].sort((a, b) => a.localeCompare(b));
|
|
2894
|
+
|
|
2895
|
+
const choice = await ctx.ui.select(title, ["inherit", ...refs, CUSTOM]);
|
|
2896
|
+
if (!choice) return undefined;
|
|
2897
|
+
if (choice !== CUSTOM) return choice;
|
|
2898
|
+
|
|
2899
|
+
const typed = await ctx.ui.input("Model (provider/modelId, or inherit)", current ?? "");
|
|
2900
|
+
const trimmed = typed?.trim();
|
|
2901
|
+
if (!trimmed) return undefined;
|
|
2902
|
+
if (!isModelReference(trimmed)) {
|
|
2903
|
+
// Refuse here rather than at save: saveSettings drops unrecognized fields
|
|
2904
|
+
// silently, which would show a success toast for a setting that vanished.
|
|
2905
|
+
ctx.ui.notify(`"${trimmed}" is not a model reference. Use provider/modelId, or inherit.`, "warning");
|
|
2906
|
+
return undefined;
|
|
2907
|
+
}
|
|
2908
|
+
return trimmed;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
async function pickDefaultModel(ctx: ExtensionCommandContext) {
|
|
2912
|
+
const chosen = await pickModelReference(ctx, "Default model", getDefaultModel());
|
|
2913
|
+
if (chosen === undefined) return;
|
|
2914
|
+
setDefaultModel(chosen);
|
|
2915
|
+
notifyApplied(
|
|
2916
|
+
ctx,
|
|
2917
|
+
chosen === "inherit"
|
|
2918
|
+
? "Default model set to inherit — subagents follow the parent session."
|
|
2919
|
+
: `Default model set to ${chosen}. Applies to spawns where no tier picks a model.`,
|
|
2920
|
+
);
|
|
2921
|
+
}
|
|
2811
2922
|
|
|
2812
2923
|
async function showSettings(ctx: ExtensionCommandContext) {
|
|
2813
2924
|
function buildItems(): SettingItem[] {
|
|
@@ -2822,6 +2933,8 @@ Do not wrap the response in a markdown code fence. Return only the file contents
|
|
|
2822
2933
|
// persist a fallback that would hard-error on every dispatch.
|
|
2823
2934
|
const fallbackValue = getFallbackSubagent() ?? "general-purpose";
|
|
2824
2935
|
const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
|
|
2936
|
+
const defaultModelLabel = describeModelReference(getDefaultModel(), ctx.modelRegistry);
|
|
2937
|
+
const tierKeys = listAgentTierKeys(getAgentTiersSettings());
|
|
2825
2938
|
|
|
2826
2939
|
return [
|
|
2827
2940
|
{
|
|
@@ -2852,6 +2965,26 @@ Do not wrap the response in a markdown code fence. Return only the file contents
|
|
|
2852
2965
|
currentValue: String(msd),
|
|
2853
2966
|
values: [String(msd)],
|
|
2854
2967
|
},
|
|
2968
|
+
{
|
|
2969
|
+
id: "defaultModel",
|
|
2970
|
+
label: "Default model",
|
|
2971
|
+
description:
|
|
2972
|
+
"Model a subagent runs when no tier applies (Enter to choose). \"inherit\" follows the parent session. A tier always wins over this.",
|
|
2973
|
+
currentValue: defaultModelLabel,
|
|
2974
|
+
// Single-value list: the real choice is a picker, opened on Enter,
|
|
2975
|
+
// because cycling a registry of models one keypress at a time is not
|
|
2976
|
+
// a usable way to pick one.
|
|
2977
|
+
values: [defaultModelLabel],
|
|
2978
|
+
},
|
|
2979
|
+
{
|
|
2980
|
+
id: "defaultTier",
|
|
2981
|
+
label: "Default tier",
|
|
2982
|
+
description: tierKeys.length > 0
|
|
2983
|
+
? "Tier applied when neither the caller nor the agent names one. Edit the tiers themselves in /agents → Model tiers."
|
|
2984
|
+
: "No tiers defined yet — create one in /agents → Model tiers.",
|
|
2985
|
+
currentValue: getAgentTiersSettings().defaultTier ?? NO_DEFAULT_TIER,
|
|
2986
|
+
values: [NO_DEFAULT_TIER, ...tierKeys],
|
|
2987
|
+
},
|
|
2855
2988
|
{
|
|
2856
2989
|
id: "joinMode",
|
|
2857
2990
|
label: "Join mode",
|
|
@@ -2951,6 +3084,15 @@ Do not wrap the response in a markdown code fence. Return only the file contents
|
|
|
2951
3084
|
: `Nested depth set to ${n}. Applies to agents started from now on.`,
|
|
2952
3085
|
);
|
|
2953
3086
|
}
|
|
3087
|
+
} else if (id === "defaultTier") {
|
|
3088
|
+
const tier = value === NO_DEFAULT_TIER ? undefined : value;
|
|
3089
|
+
setAgentTiersSettings(setDefaultAgentTier(getAgentTiersSettings(), tier));
|
|
3090
|
+
notifyApplied(
|
|
3091
|
+
ctx,
|
|
3092
|
+
tier === undefined
|
|
3093
|
+
? "Default tier cleared. Spawns that name no tier use the default model."
|
|
3094
|
+
: `Default tier set to ${tier}. The tool description updates on the next pi session.`,
|
|
3095
|
+
);
|
|
2954
3096
|
} else if (id === "joinMode") {
|
|
2955
3097
|
setDefaultJoinMode(value as JoinMode);
|
|
2956
3098
|
notifyApplied(ctx, `Default join mode set to ${value}`);
|
|
@@ -3034,9 +3176,10 @@ Do not wrap the response in a markdown code fence. Return only the file contents
|
|
|
3034
3176
|
currentIndex = Math.min(items.length - 1, currentIndex + 1);
|
|
3035
3177
|
}
|
|
3036
3178
|
|
|
3037
|
-
// Enter on numeric field → close and
|
|
3038
|
-
|
|
3039
|
-
|
|
3179
|
+
// Enter on a numeric or picker field → close and open its dialog
|
|
3180
|
+
const focusedId = items[currentIndex].id;
|
|
3181
|
+
if (matchesKey(data, Key.enter) && (NUMERIC_IDS.has(focusedId) || PICKER_IDS.has(focusedId))) {
|
|
3182
|
+
done(focusedId);
|
|
3040
3183
|
return;
|
|
3041
3184
|
}
|
|
3042
3185
|
list.handleInput?.(data);
|
|
@@ -3044,6 +3187,12 @@ Do not wrap the response in a markdown code fence. Return only the file contents
|
|
|
3044
3187
|
};
|
|
3045
3188
|
});
|
|
3046
3189
|
|
|
3190
|
+
if (result === "defaultModel") {
|
|
3191
|
+
await pickDefaultModel(ctx);
|
|
3192
|
+
await showSettings(ctx);
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3047
3196
|
// If a numeric field ID was returned, prompt for typed input
|
|
3048
3197
|
if (result && NUMERIC_IDS.has(result)) {
|
|
3049
3198
|
const current = result === "maxConcurrent"
|
|
@@ -3079,6 +3228,162 @@ Do not wrap the response in a markdown code fence. Return only the file contents
|
|
|
3079
3228
|
}
|
|
3080
3229
|
}
|
|
3081
3230
|
|
|
3231
|
+
// ---- /agents → Model tiers ----
|
|
3232
|
+
//
|
|
3233
|
+
// The catalogue only; `defaultTier` stays in Settings next to the other
|
|
3234
|
+
// defaults. Every mutation here goes through the pure helpers in
|
|
3235
|
+
// agent-tiers.ts, so the rules that outlive a menu — retiring a tombstone,
|
|
3236
|
+
// never leaving `defaultTier` pointing at a deleted tier — are tested without
|
|
3237
|
+
// a terminal.
|
|
3238
|
+
|
|
3239
|
+
/** One catalogue row: what the tier resolves to on this machine. */
|
|
3240
|
+
function describeTierRow(key: string, ctx: ExtensionCommandContext): string {
|
|
3241
|
+
const settings = getAgentTiersSettings();
|
|
3242
|
+
const profile = settings.profiles?.[key];
|
|
3243
|
+
if (!profile) return `${key} — blocked (malformed profile in subagents.json)`;
|
|
3244
|
+
const suffix = settings.defaultTier === key ? " (default)" : "";
|
|
3245
|
+
const model = describeModelReference(profile.model, ctx.modelRegistry);
|
|
3246
|
+
return `${key} — ${model} · thinking ${profile.thinking}${suffix}`;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
/**
|
|
3250
|
+
* Ask for a tier's thinking level, offering only what its model supports.
|
|
3251
|
+
*
|
|
3252
|
+
* A model with no thinking support at all leaves `inherit` as the single
|
|
3253
|
+
* honest answer, so it is stored without a one-item menu to click through.
|
|
3254
|
+
*/
|
|
3255
|
+
async function pickTierThinking(
|
|
3256
|
+
ctx: ExtensionCommandContext,
|
|
3257
|
+
modelRef: string,
|
|
3258
|
+
current?: TierThinking,
|
|
3259
|
+
): Promise<TierThinking | undefined> {
|
|
3260
|
+
const levels = offerableTierThinking(modelRef, ctx.modelRegistry);
|
|
3261
|
+
if (levels.length === 1) {
|
|
3262
|
+
ctx.ui.notify(
|
|
3263
|
+
`${describeModelReference(modelRef, ctx.modelRegistry)} supports no thinking levels — storing "inherit".`,
|
|
3264
|
+
"info",
|
|
3265
|
+
);
|
|
3266
|
+
return "inherit";
|
|
3267
|
+
}
|
|
3268
|
+
const choice = await ctx.ui.select(
|
|
3269
|
+
current ? `Thinking level (now ${current})` : "Thinking level",
|
|
3270
|
+
levels,
|
|
3271
|
+
);
|
|
3272
|
+
return choice as TierThinking | undefined;
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
/** Write one profile and report it, naming the delay the tool description has. */
|
|
3276
|
+
function saveTierProfile(ctx: ExtensionCommandContext, key: string, profile: AgentTierProfile, verb: string) {
|
|
3277
|
+
setAgentTiersSettings(upsertAgentTierProfile(getAgentTiersSettings(), key, profile));
|
|
3278
|
+
notifyApplied(ctx, `Tier "${key}" ${verb}. The Agent tool description updates on the next pi session.`);
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
/**
|
|
3282
|
+
* Define a tier from scratch.
|
|
3283
|
+
*
|
|
3284
|
+
* `presetKey` skips the name prompt: redefining a blocked tier already knows
|
|
3285
|
+
* which name it is fixing, and asking again invites a typo that would leave
|
|
3286
|
+
* the tombstone in place next to a near-miss twin.
|
|
3287
|
+
*/
|
|
3288
|
+
async function createTier(ctx: ExtensionCommandContext, presetKey?: string) {
|
|
3289
|
+
const rawKey = presetKey ?? (await ctx.ui.input("Tier name (one word, no spaces)"));
|
|
3290
|
+
if (!rawKey) return;
|
|
3291
|
+
const key = rawKey.trim();
|
|
3292
|
+
if (!isValidAgentTierKey(key)) {
|
|
3293
|
+
ctx.ui.notify(
|
|
3294
|
+
`"${key}" is not a tier name. Use one word, no whitespace, at most ${MAX_AGENT_TIER_KEY_LENGTH} characters.`,
|
|
3295
|
+
"warning",
|
|
3296
|
+
);
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
if (getAgentTiersSettings().profiles?.[key]) {
|
|
3300
|
+
ctx.ui.notify(`Tier "${key}" already exists. Pick it from the list to edit it.`, "warning");
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
const model = await pickModelReference(ctx, `Model for "${key}"`, undefined);
|
|
3305
|
+
if (model === undefined) return;
|
|
3306
|
+
const thinking = await pickTierThinking(ctx, model);
|
|
3307
|
+
if (thinking === undefined) return;
|
|
3308
|
+
// Description is what the host agent reads when choosing between tiers, so
|
|
3309
|
+
// it is prose about the job, not about the model. Blank falls back to the key.
|
|
3310
|
+
const description = await ctx.ui.input(`What is "${key}" for? (shown to the model, optional)`);
|
|
3311
|
+
|
|
3312
|
+
const trimmed = description?.trim();
|
|
3313
|
+
saveTierProfile(ctx, key, { model, thinking, ...(trimmed ? { description: trimmed } : {}) }, "created");
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
async function editTier(ctx: ExtensionCommandContext, key: string) {
|
|
3317
|
+
const profile = getAgentTiersSettings().profiles?.[key];
|
|
3318
|
+
if (!profile) {
|
|
3319
|
+
// A blocked key has no profile to edit; redefining it is the documented
|
|
3320
|
+
// fix, and upsert retires the tombstone in the same write.
|
|
3321
|
+
const redefine = await ctx.ui.confirm(
|
|
3322
|
+
"Blocked tier",
|
|
3323
|
+
`"${key}" was dropped as malformed. Define it again to unblock it?`,
|
|
3324
|
+
);
|
|
3325
|
+
if (redefine) await createTier(ctx, key);
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
const MODEL = "Model";
|
|
3330
|
+
const THINKING = "Thinking";
|
|
3331
|
+
const DESCRIPTION = "Description";
|
|
3332
|
+
const DELETE = "Delete tier";
|
|
3333
|
+
const choice = await ctx.ui.select(`Tier "${key}"`, [MODEL, THINKING, DESCRIPTION, DELETE]);
|
|
3334
|
+
if (!choice) return;
|
|
3335
|
+
|
|
3336
|
+
if (choice === MODEL) {
|
|
3337
|
+
const model = await pickModelReference(ctx, `Model for "${key}"`, profile.model);
|
|
3338
|
+
if (model === undefined) return;
|
|
3339
|
+
saveTierProfile(ctx, key, { ...profile, model }, `now runs ${model}`);
|
|
3340
|
+
} else if (choice === THINKING) {
|
|
3341
|
+
const thinking = await pickTierThinking(ctx, profile.model, profile.thinking);
|
|
3342
|
+
if (thinking === undefined) return;
|
|
3343
|
+
saveTierProfile(ctx, key, { ...profile, thinking }, `now thinks ${thinking}`);
|
|
3344
|
+
} else if (choice === DESCRIPTION) {
|
|
3345
|
+
const description = await ctx.ui.input(`What is "${key}" for?`, profile.description ?? "");
|
|
3346
|
+
if (description === undefined) return;
|
|
3347
|
+
const trimmed = description.trim();
|
|
3348
|
+
const { description: _dropped, ...rest } = profile;
|
|
3349
|
+
saveTierProfile(ctx, key, { ...rest, ...(trimmed ? { description: trimmed } : {}) }, "description updated");
|
|
3350
|
+
} else if (choice === DELETE) {
|
|
3351
|
+
const wasDefault = getAgentTiersSettings().defaultTier === key;
|
|
3352
|
+
const confirmed = await ctx.ui.confirm(
|
|
3353
|
+
"Delete tier",
|
|
3354
|
+
wasDefault
|
|
3355
|
+
? `Delete "${key}"? It is the default tier, so the default is cleared too.`
|
|
3356
|
+
: `Delete "${key}"? Agents whose frontmatter names it will fail to spawn until you fix them.`,
|
|
3357
|
+
);
|
|
3358
|
+
if (!confirmed) return;
|
|
3359
|
+
setAgentTiersSettings(removeAgentTierProfile(getAgentTiersSettings(), key));
|
|
3360
|
+
notifyApplied(
|
|
3361
|
+
ctx,
|
|
3362
|
+
wasDefault
|
|
3363
|
+
? `Tier "${key}" deleted and the default tier cleared.`
|
|
3364
|
+
: `Tier "${key}" deleted.`,
|
|
3365
|
+
);
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
async function showModelTiersMenu(ctx: ExtensionCommandContext) {
|
|
3370
|
+
const settings = getAgentTiersSettings();
|
|
3371
|
+
// Blocked keys are listed alongside defined ones: they are the entries most
|
|
3372
|
+
// in need of attention, and hiding them would leave a tier that refuses
|
|
3373
|
+
// every spawn invisible in the menu that exists to manage tiers.
|
|
3374
|
+
const keys = [...new Set([...listAgentTierKeys(settings), ...(settings.blockedProfiles ?? [])])]
|
|
3375
|
+
.sort((a, b) => a.localeCompare(b));
|
|
3376
|
+
|
|
3377
|
+
const rows = keys.map(key => describeTierRow(key, ctx));
|
|
3378
|
+
const choice = await ctx.ui.select("Model tiers", [...rows, NEW_TIER_ENTRY]);
|
|
3379
|
+
if (!choice) return;
|
|
3380
|
+
|
|
3381
|
+
if (choice === NEW_TIER_ENTRY) await createTier(ctx);
|
|
3382
|
+
else await editTier(ctx, keys[rows.indexOf(choice)]);
|
|
3383
|
+
|
|
3384
|
+
await showModelTiersMenu(ctx);
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3082
3387
|
// Persist the current snapshot, emit `subagents:settings_changed`, and surface
|
|
3083
3388
|
// the right toast. Successful saves show info; persistence failures downgrade
|
|
3084
3389
|
// to warning so users aren't silently reverted on restart. Event fires regardless
|
package/src/settings.ts
CHANGED
|
@@ -82,6 +82,25 @@ export interface SubagentsSettings {
|
|
|
82
82
|
* these. See `agent-tiers.ts` for resolution and precedence.
|
|
83
83
|
*/
|
|
84
84
|
agentTiers?: AgentTiersSettings;
|
|
85
|
+
/**
|
|
86
|
+
* The model a subagent runs when nothing else chose one — no tier applied and
|
|
87
|
+
* no programmatic override. It takes the place of the parent session's model
|
|
88
|
+
* as the last step of resolution, so a workspace can say "subagents run on the
|
|
89
|
+
* cheap model" without first defining a tier catalogue.
|
|
90
|
+
*
|
|
91
|
+
* A `provider/model` reference, or the literal `"inherit"` to follow the
|
|
92
|
+
* parent. `"inherit"` is spellable rather than merely omittable because a
|
|
93
|
+
* project needs a way to undo a global default; omitting the key inherits
|
|
94
|
+
* whatever global set.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately weaker than a tier: a tier naming an unavailable model fails
|
|
97
|
+
* the spawn, because someone asked for that policy by name, while an
|
|
98
|
+
* unresolvable default model falls back to the parent. Failing every spawn on
|
|
99
|
+
* a machine that happens to lack one provider is the wrong trade for a value
|
|
100
|
+
* nobody named at the call site — the `/agents → Settings` row shows the
|
|
101
|
+
* fallback instead.
|
|
102
|
+
*/
|
|
103
|
+
defaultModel?: string;
|
|
85
104
|
maxConcurrent?: number;
|
|
86
105
|
/**
|
|
87
106
|
* 0 = unlimited — the extension's single source of truth for that convention:
|
|
@@ -195,6 +214,8 @@ export interface SettingsAppliers {
|
|
|
195
214
|
setDefaultMaxTurns: (n: number) => void;
|
|
196
215
|
setGraceTurns: (n: number) => void;
|
|
197
216
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
217
|
+
/** `undefined` and `"inherit"` both mean "follow the parent session". */
|
|
218
|
+
setDefaultModel: (ref: string | undefined) => void;
|
|
198
219
|
setSchedulingEnabled: (b: boolean) => void;
|
|
199
220
|
setScopeModels: (enabled: boolean) => void;
|
|
200
221
|
setStrictAgentFiles: (b: boolean) => void;
|
|
@@ -215,15 +236,23 @@ export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
|
215
236
|
|
|
216
237
|
const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
|
|
217
238
|
const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
|
|
218
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Thinking values a tier profile accepts, `inherit` first and then ascending.
|
|
241
|
+
*
|
|
242
|
+
* Ordered because the `/agents → Model tiers` editor offers them in this order;
|
|
243
|
+
* note `off` is absent — a profile that wants no thinking says so through the
|
|
244
|
+
* model it names, and `clampThinkingLevel` handles a model that supports none.
|
|
245
|
+
*/
|
|
246
|
+
export const TIER_THINKING_LEVELS: readonly TierThinking[] = [
|
|
247
|
+
"inherit",
|
|
219
248
|
"minimal",
|
|
220
249
|
"low",
|
|
221
250
|
"medium",
|
|
222
251
|
"high",
|
|
223
252
|
"xhigh",
|
|
224
253
|
"max",
|
|
225
|
-
|
|
226
|
-
|
|
254
|
+
];
|
|
255
|
+
const VALID_THINKING_LEVELS: ReadonlySet<string> = new Set(TIER_THINKING_LEVELS);
|
|
227
256
|
const WORKFLOW_TIER_NAMES: readonly WorkflowTier[] = ["small", "medium", "large"];
|
|
228
257
|
const MAX_MODEL_REFERENCE_LENGTH = 512;
|
|
229
258
|
/** Mirrors MAX_AGENT_TIER_KEY_LENGTH in agent-tiers.ts; duplicated to keep settings dependency-free. */
|
|
@@ -244,7 +273,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
244
273
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
245
274
|
}
|
|
246
275
|
|
|
247
|
-
|
|
276
|
+
/**
|
|
277
|
+
* `inherit`, or a bounded whitespace-free `provider/model` reference.
|
|
278
|
+
*
|
|
279
|
+
* Exported so the `/agents` menus can reject a typed reference at the prompt.
|
|
280
|
+
* Without that they would hand an invalid value to `saveSettings`, which drops
|
|
281
|
+
* unrecognized fields silently — a success toast for a setting that never
|
|
282
|
+
* persisted.
|
|
283
|
+
*/
|
|
284
|
+
export function isModelReference(value: unknown): value is string {
|
|
248
285
|
if (typeof value !== "string") return false;
|
|
249
286
|
const model = value.trim();
|
|
250
287
|
if (model === "inherit") return true;
|
|
@@ -267,7 +304,7 @@ function sanitizeWorkflowProfile(raw: unknown): WorkflowTierProfile | undefined
|
|
|
267
304
|
const keys = Object.keys(raw);
|
|
268
305
|
if (keys.some((key) => key !== "model" && key !== "thinking")) return undefined;
|
|
269
306
|
if (!Object.hasOwn(raw, "model") || !Object.hasOwn(raw, "thinking")) return undefined;
|
|
270
|
-
if (!
|
|
307
|
+
if (!isModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
|
|
271
308
|
return { model: raw.model.trim(), thinking: raw.thinking };
|
|
272
309
|
}
|
|
273
310
|
|
|
@@ -318,7 +355,7 @@ function sanitizeAgentTierProfile(raw: unknown): AgentTierProfile | undefined {
|
|
|
318
355
|
const keys = Object.keys(raw);
|
|
319
356
|
if (keys.some((key) => key !== "model" && key !== "thinking" && key !== "description")) return undefined;
|
|
320
357
|
if (!Object.hasOwn(raw, "model") || !Object.hasOwn(raw, "thinking")) return undefined;
|
|
321
|
-
if (!
|
|
358
|
+
if (!isModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
|
|
322
359
|
if (
|
|
323
360
|
Object.hasOwn(raw, "description") &&
|
|
324
361
|
(typeof raw.description !== "string" ||
|
|
@@ -397,6 +434,9 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
397
434
|
) {
|
|
398
435
|
out.maxSubagentDepth = r.maxSubagentDepth as number;
|
|
399
436
|
}
|
|
437
|
+
if (isModelReference(r.defaultModel)) {
|
|
438
|
+
out.defaultModel = r.defaultModel.trim();
|
|
439
|
+
}
|
|
400
440
|
if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
|
|
401
441
|
out.defaultJoinMode = r.defaultJoinMode as JoinMode;
|
|
402
442
|
}
|
|
@@ -773,6 +813,9 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
773
813
|
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
774
814
|
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
775
815
|
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
|
|
816
|
+
// Applied whenever the key is present, `"inherit"` included: that spelling is
|
|
817
|
+
// how a project cancels a global default model, so it has to reach the setter.
|
|
818
|
+
if (typeof s.defaultModel === "string") appliers.setDefaultModel(s.defaultModel);
|
|
776
819
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
777
820
|
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
|
|
778
821
|
if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
|
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. */
|