@signalridge/pi-subagents 1.8.0 → 1.9.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 +170 -0
- package/README.md +85 -55
- package/package.json +2 -2
- package/src/agent-manager.ts +235 -120
- package/src/agent-runner.ts +90 -72
- package/src/agent-tiers.ts +236 -70
- package/src/cross-extension-rpc.ts +6 -0
- package/src/default-agents.ts +3 -3
- package/src/index.ts +149 -139
- package/src/internal-run.ts +14 -0
- package/src/invocation-config.ts +24 -13
- package/src/model-resolver.ts +12 -0
- package/src/model-scope.ts +5 -2
- package/src/nested-tools.ts +35 -22
- package/src/schedule.ts +3 -0
- package/src/settings.ts +68 -247
- package/src/types.ts +10 -13
- package/src/ui/agent-display.ts +1 -1
- package/src/workflow-tiers.ts +0 -202
package/src/agent-tiers.ts
CHANGED
|
@@ -1,29 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent-tiers.ts — user-named model tiers
|
|
2
|
+
* agent-tiers.ts — user-named model tiers. The one tier catalogue.
|
|
3
3
|
*
|
|
4
4
|
* A tier is one name for a (model, thinking) pair. The host agent picks a tier
|
|
5
5
|
* key and nothing else: the LLM-facing `Agent` tool exposes `tier` and does not
|
|
6
6
|
* expose `model` or `thinking`, so the choice of which model runs stays with
|
|
7
7
|
* whoever writes `subagents.json` rather than with the model deciding per call.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* both use — resolving a model reference, clamping thinking — are short enough
|
|
16
|
-
* to state twice; `workflow-tiers.ts` keeps its own copy so this change cannot
|
|
17
|
-
* alter the protocol-facing resolver's behavior.
|
|
18
|
-
*
|
|
19
|
-
* The two also disagree on precedence, which is why they are not one function:
|
|
20
|
-
* a workflow tier only fills what agent frontmatter left blank, while an agent
|
|
21
|
-
* tier overrides frontmatter's legacy `model:`/`thinking:`. That is the point of
|
|
22
|
-
* agent tiers — the tier is the policy, and a per-agent pin is the older, weaker
|
|
23
|
-
* statement of the same thing.
|
|
9
|
+
* Managed `pi-workflows` calls name a key from this same catalogue. There is no
|
|
10
|
+
* separate workflow-tier vocabulary and no mapping layer: a workflow that wants
|
|
11
|
+
* cheap work asks for the tier the user defined for cheap work, and gets the
|
|
12
|
+
* same resolver, the same precedence, and the same fail-closed errors an
|
|
13
|
+
* ordinary spawn gets. `getRoutingPolicySnapshot()` publishes the part of this
|
|
14
|
+
* catalogue a managed peer needs to reason about replay.
|
|
24
15
|
*/
|
|
25
16
|
|
|
26
17
|
import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
|
|
18
|
+
import type { ManagedRoutingPolicy, ManagedRoutingPolicySnapshot } from "@signalridge/pi-subagents-protocol";
|
|
19
|
+
import { isManagedAgentTier, MAX_AGENT_TIER_KEY_LENGTH, routingPolicyFingerprint } from "@signalridge/pi-subagents-protocol";
|
|
27
20
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
28
21
|
import { type AgentTierProfile, type AgentTiersSettings, TIER_THINKING_LEVELS, type TierThinking } from "./settings.js";
|
|
29
22
|
import type { AgentConfig, ThinkingLevel } from "./types.js";
|
|
@@ -33,33 +26,99 @@ function effectiveModelId(model: Model<Api> | undefined): string | undefined {
|
|
|
33
26
|
return model ? `${model.provider}/${model.id}` : undefined;
|
|
34
27
|
}
|
|
35
28
|
|
|
36
|
-
/** Longest accepted tier key. Long enough for any real name, short enough to render. */
|
|
37
|
-
export const MAX_AGENT_TIER_KEY_LENGTH = 64;
|
|
38
|
-
|
|
39
29
|
/**
|
|
40
|
-
* The tier
|
|
30
|
+
* The tier-key bound and predicate, re-exported from the protocol package.
|
|
41
31
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* provider-neutral (`inherit` model, low thinking), and any user who defines
|
|
47
|
-
* `fast` in `subagents.json` replaces it wholesale.
|
|
48
|
-
*
|
|
49
|
-
* It is not shown as an available tier until settings are loaded; the merge in
|
|
50
|
-
* `setAgentTiersSettings` is where a worktree that explicitly disables/renames
|
|
51
|
-
* `fast` can win.
|
|
32
|
+
* They are defined once, on the wire, because that is the narrower of the two
|
|
33
|
+
* gates: a key this package accepted but the protocol rejected could never be
|
|
34
|
+
* sent to a managed peer. Everything in pi-subagents imports them from here so
|
|
35
|
+
* there is still one import site inside the package.
|
|
52
36
|
*/
|
|
53
|
-
|
|
37
|
+
export { isManagedAgentTier as isValidAgentTierKey, MAX_AGENT_TIER_KEY_LENGTH };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fresh installs receive an effort ladder: `low`, `medium`, `high`. Every
|
|
41
|
+
* shipped profile inherits its model, so a new machine gets a usable vocabulary
|
|
42
|
+
* without this package ever pinning a vendor. A user definition in
|
|
43
|
+
* `subagents.json` replaces a shipped profile wholesale; a blocked profile is
|
|
44
|
+
* never resurrected.
|
|
45
|
+
*/
|
|
46
|
+
const SHIPPED_LOW_PROFILE: AgentTierProfile = {
|
|
54
47
|
model: "inherit",
|
|
55
48
|
thinking: "low",
|
|
56
|
-
description: "
|
|
49
|
+
description: "Cheap, shallow work (shipped)",
|
|
50
|
+
};
|
|
51
|
+
const SHIPPED_MEDIUM_PROFILE: AgentTierProfile = {
|
|
52
|
+
model: "inherit",
|
|
53
|
+
thinking: "medium",
|
|
54
|
+
description: "Ordinary work (shipped)",
|
|
55
|
+
};
|
|
56
|
+
const SHIPPED_HIGH_PROFILE: AgentTierProfile = {
|
|
57
|
+
model: "inherit",
|
|
58
|
+
thinking: "high",
|
|
59
|
+
description: "Deep or risky work (shipped)",
|
|
57
60
|
};
|
|
58
61
|
|
|
59
62
|
export const SHIPPED_AGENT_TIER_PROFILES: Readonly<Record<string, AgentTierProfile>> = {
|
|
60
|
-
|
|
63
|
+
low: SHIPPED_LOW_PROFILE,
|
|
64
|
+
medium: SHIPPED_MEDIUM_PROFILE,
|
|
65
|
+
high: SHIPPED_HIGH_PROFILE,
|
|
61
66
|
};
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The tier a *managed* call gets when nobody named one.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately not a global `defaultTier`. A managed workflow call fails closed
|
|
72
|
+
* without a tier, and "install the package, run a workflow, get a hard error"
|
|
73
|
+
* is not a defensible first experience — but that is the only case that needs a
|
|
74
|
+
* shipped answer. Making it the catalogue's default instead would take over
|
|
75
|
+
* every ordinary spawn as well, which would silence `defaultModel` and pin a
|
|
76
|
+
* thinking level on machines that asked for neither.
|
|
77
|
+
*
|
|
78
|
+
* `medium` inherits its model, so this commits to an effort level, not to a
|
|
79
|
+
* vendor — and, being `inherit`, it lands on the parent session's model. That
|
|
80
|
+
* is the honest trade: this fallback exists so a managed call has a *named*
|
|
81
|
+
* policy with a durable snapshot on a machine that configured none, not so it
|
|
82
|
+
* runs somewhere cheaper. A workspace that wants cheaper managed work names a
|
|
83
|
+
* `defaultTier` whose profile pins a model.
|
|
84
|
+
*
|
|
85
|
+
* It applies only while the user has expressed no opinion: any configured
|
|
86
|
+
* `defaultTier` wins, `noDefaultTier` suppresses it outright, a tombstoned
|
|
87
|
+
* default still blocks, and deleting the `medium` profile removes this fallback
|
|
88
|
+
* with it.
|
|
89
|
+
*/
|
|
90
|
+
export const SHIPPED_DEFAULT_AGENT_TIER = "medium";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The default a managed call resolves against, or `undefined` when it must fail
|
|
94
|
+
* closed. The single definition of the shipped fallback: `selectAgentTier` consults
|
|
95
|
+
* it for a `requireTier` spawn and `getRoutingPolicySnapshot` publishes it, so a
|
|
96
|
+
* managed peer's replay identity can never disagree with what the host will do.
|
|
97
|
+
*/
|
|
98
|
+
export function managedDefaultAgentTier(settings: AgentTiersSettings): string | undefined {
|
|
99
|
+
if (settings.blockedDefaultTier === true) return undefined;
|
|
100
|
+
if (settings.defaultTier !== undefined) return settings.defaultTier;
|
|
101
|
+
if (settings.noDefaultTier === true) return undefined;
|
|
102
|
+
return settings.profiles?.[SHIPPED_DEFAULT_AGENT_TIER] ? SHIPPED_DEFAULT_AGENT_TIER : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What leaving `defaultTier` unset would actually give a managed call, whatever
|
|
107
|
+
* the user has chosen right now.
|
|
108
|
+
*
|
|
109
|
+
* The Settings menu offers `unset` as a distinct choice from `none`, and the
|
|
110
|
+
* difference between them is exactly this value — which is `undefined` on a
|
|
111
|
+
* catalogue whose `medium` profile has been edited away or tombstoned. A menu
|
|
112
|
+
* that says "unset uses the shipped medium" there would be describing a
|
|
113
|
+
* fallback that no longer exists, and `unset` would behave identically to
|
|
114
|
+
* `none` while claiming otherwise. Asked of the same function that decides it,
|
|
115
|
+
* with the current choice stripped, so the answer cannot drift from the
|
|
116
|
+
* behavior.
|
|
117
|
+
*/
|
|
118
|
+
export function shippedFallbackAgentTier(settings: AgentTiersSettings = agentTiersSettings): string | undefined {
|
|
119
|
+
return managedDefaultAgentTier({ ...settings, defaultTier: undefined, noDefaultTier: false });
|
|
120
|
+
}
|
|
121
|
+
|
|
63
122
|
let agentTiersSettings: AgentTiersSettings = {}; // effective view (shipped tiers merged)
|
|
64
123
|
let agentTiersConfigured: AgentTiersSettings = {}; // exactly what the user configured
|
|
65
124
|
|
|
@@ -81,12 +140,12 @@ function sameProfile(a: AgentTierProfile, b: AgentTierProfile): boolean {
|
|
|
81
140
|
/**
|
|
82
141
|
* Install the effective tier catalogue.
|
|
83
142
|
*
|
|
84
|
-
*
|
|
85
|
-
* explicitly blocked it — a user catalogue wins over
|
|
86
|
-
* tombstone means "do not substitute", which applies to shipped
|
|
143
|
+
* Each shipped tier is merged in unless the caller already defined it or
|
|
144
|
+
* explicitly blocked it — a user catalogue wins over a shipped profile, and a
|
|
145
|
+
* tombstone means "do not substitute", which applies to shipped profiles too.
|
|
87
146
|
*
|
|
88
147
|
* The configured view is derived from the same input by stripping profiles that
|
|
89
|
-
* exactly equal a shipped
|
|
148
|
+
* exactly equal a shipped profile, so the UI can operate on the effective view
|
|
90
149
|
* and send it back without materializing untouched shipped tiers into
|
|
91
150
|
* `subagents.json`. Editing a shipped tier (changing its model, thinking, or
|
|
92
151
|
* description) makes it a user-owned profile and it is then persisted; deleting
|
|
@@ -107,6 +166,11 @@ export function setAgentTiersSettings(settings: AgentTiersSettings): void {
|
|
|
107
166
|
if (!blocked.has(key) && profiles[key] === undefined) profiles[key] = profile;
|
|
108
167
|
}
|
|
109
168
|
|
|
169
|
+
// `defaultTier` is passed through untouched. The shipped fallback is not
|
|
170
|
+
// merged in here: it is scoped to managed calls (see
|
|
171
|
+
// `managedDefaultAgentTier`), so the effective catalogue keeps saying "the
|
|
172
|
+
// user named no default" and an ordinary spawn still falls through to
|
|
173
|
+
// `defaultModel` and the parent session.
|
|
110
174
|
agentTiersSettings = { ...effective, profiles };
|
|
111
175
|
const configured: AgentTiersSettings = { ...effective };
|
|
112
176
|
if (Object.keys(configuredProfiles).length > 0) configured.profiles = configuredProfiles;
|
|
@@ -115,22 +179,12 @@ export function setAgentTiersSettings(settings: AgentTiersSettings): void {
|
|
|
115
179
|
}
|
|
116
180
|
|
|
117
181
|
/**
|
|
118
|
-
*
|
|
182
|
+
* Where the tier that was used came from; recorded for audit.
|
|
119
183
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
184
|
+
* A managed workflow call is a `call`: an orchestrator naming a tier per
|
|
185
|
+
* dispatch is the same act whether the orchestrator is the host model or a
|
|
186
|
+
* workflow script, so it gets the same precedence and the same scope policy.
|
|
122
187
|
*/
|
|
123
|
-
export function isValidAgentTierKey(value: unknown): value is string {
|
|
124
|
-
return (
|
|
125
|
-
typeof value === "string" &&
|
|
126
|
-
value.length > 0 &&
|
|
127
|
-
value.length <= MAX_AGENT_TIER_KEY_LENGTH &&
|
|
128
|
-
value.trim() === value &&
|
|
129
|
-
!/\s/u.test(value)
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Where the tier that was used came from; recorded for audit. */
|
|
134
188
|
export type AgentTierSource = "call" | "frontmatter" | "default";
|
|
135
189
|
|
|
136
190
|
/** Durable, JSON-safe record of how one spawn's model and thinking were chosen. */
|
|
@@ -162,6 +216,12 @@ export interface AgentTierResolution {
|
|
|
162
216
|
export interface ResolveAgentTierInput {
|
|
163
217
|
/** Tier key from the spawn call. Highest precedence. */
|
|
164
218
|
requestedTier?: string;
|
|
219
|
+
/**
|
|
220
|
+
* This spawn cannot fall back to the parent session's model, so the shipped
|
|
221
|
+
* fallback applies when nothing else named a tier. Managed workflow calls set
|
|
222
|
+
* it; an ordinary spawn leaves it off and simply resolves no tier.
|
|
223
|
+
*/
|
|
224
|
+
requireTier?: boolean;
|
|
165
225
|
/** The agent's own config; supplies its default tier and legacy model/thinking. */
|
|
166
226
|
agentConfig?: AgentConfig;
|
|
167
227
|
/** Overrides the module-level settings; tests and callers with their own load. */
|
|
@@ -171,6 +231,12 @@ export interface ResolveAgentTierInput {
|
|
|
171
231
|
modelRegistry: ModelRegistry<Model<Api>>;
|
|
172
232
|
}
|
|
173
233
|
|
|
234
|
+
/** The part of a resolve request that decides *which* tier applies. */
|
|
235
|
+
export type TierSelectionInput = Pick<
|
|
236
|
+
ResolveAgentTierInput,
|
|
237
|
+
"requestedTier" | "requireTier" | "agentConfig" | "settings"
|
|
238
|
+
>;
|
|
239
|
+
|
|
174
240
|
/** Thrown for every fail-closed tier condition so callers can report it verbatim. */
|
|
175
241
|
export class AgentTierError extends Error {
|
|
176
242
|
constructor(message: string) {
|
|
@@ -208,6 +274,7 @@ function withoutBlocked(blocked: string[] | undefined, key: string): string[] |
|
|
|
208
274
|
function compactTierSettings(settings: AgentTiersSettings): AgentTiersSettings {
|
|
209
275
|
const out: AgentTiersSettings = {};
|
|
210
276
|
if (settings.defaultTier !== undefined) out.defaultTier = settings.defaultTier;
|
|
277
|
+
else if (settings.noDefaultTier) out.noDefaultTier = true;
|
|
211
278
|
if (settings.profiles && Object.keys(settings.profiles).length > 0) out.profiles = settings.profiles;
|
|
212
279
|
if (settings.blockedProfiles && settings.blockedProfiles.length > 0) {
|
|
213
280
|
out.blockedProfiles = settings.blockedProfiles;
|
|
@@ -242,13 +309,11 @@ export function upsertAgentTierProfile(
|
|
|
242
309
|
* turn every later spawn that names no tier into a hard refusal, which is a
|
|
243
310
|
* strange thing to get from deleting a tier you had stopped using.
|
|
244
311
|
*
|
|
245
|
-
* Deleting a shipped tier
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* frontmatter, so the spawn refusal then says so loudly until the agent file or
|
|
251
|
-
* the tier is fixed.
|
|
312
|
+
* Deleting a shipped tier tombstones it instead of just dropping it: the
|
|
313
|
+
* shipped merge in `setAgentTiersSettings` would otherwise silently re-add it
|
|
314
|
+
* on the next load, and a user who deletes it means it. The tombstone says
|
|
315
|
+
* "do not substitute", which is exactly the semantics the load path already
|
|
316
|
+
* honors for malformed profiles.
|
|
252
317
|
*/
|
|
253
318
|
export function removeAgentTierProfile(settings: AgentTiersSettings, key: string): AgentTiersSettings {
|
|
254
319
|
const { [key]: _removed, ...profiles } = settings.profiles ?? {};
|
|
@@ -288,7 +353,30 @@ export function offerableTierThinking(
|
|
|
288
353
|
}
|
|
289
354
|
|
|
290
355
|
/**
|
|
291
|
-
*
|
|
356
|
+
* What the user chose for the default tier.
|
|
357
|
+
*
|
|
358
|
+
* `none` and `unset` are different policies, and a single "no default" value
|
|
359
|
+
* cannot express both: an absent `defaultTier` still lets a managed workflow
|
|
360
|
+
* call reach the shipped fallback, while `none` is the statement that managed
|
|
361
|
+
* calls should fail closed as well. Typed rather than modelled as
|
|
362
|
+
* `string | undefined` so a caller has to say which one it means, and so a menu
|
|
363
|
+
* can render the difference instead of showing one word for two behaviors.
|
|
364
|
+
*/
|
|
365
|
+
export type DefaultAgentTierSelection =
|
|
366
|
+
| { kind: "tier"; tier: string }
|
|
367
|
+
| { kind: "none" }
|
|
368
|
+
| { kind: "unset" };
|
|
369
|
+
|
|
370
|
+
/** Which of the three states the catalogue is currently in. */
|
|
371
|
+
export function getDefaultAgentTierSelection(
|
|
372
|
+
settings: AgentTiersSettings = agentTiersSettings,
|
|
373
|
+
): DefaultAgentTierSelection {
|
|
374
|
+
if (settings.defaultTier !== undefined) return { kind: "tier", tier: settings.defaultTier };
|
|
375
|
+
return settings.noDefaultTier === true ? { kind: "none" } : { kind: "unset" };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Set, clear, or withdraw the default tier.
|
|
292
380
|
*
|
|
293
381
|
* Always clears `blockedDefaultTier`: that tombstone describes the malformed
|
|
294
382
|
* value this call is replacing, and keeping it would make the resolver refuse
|
|
@@ -296,25 +384,46 @@ export function offerableTierThinking(
|
|
|
296
384
|
*/
|
|
297
385
|
export function setDefaultAgentTier(
|
|
298
386
|
settings: AgentTiersSettings,
|
|
299
|
-
|
|
387
|
+
selection: DefaultAgentTierSelection,
|
|
300
388
|
): AgentTiersSettings {
|
|
301
|
-
return compactTierSettings({
|
|
389
|
+
return compactTierSettings({
|
|
390
|
+
...settings,
|
|
391
|
+
defaultTier: selection.kind === "tier" ? selection.tier : undefined,
|
|
392
|
+
noDefaultTier: selection.kind === "none",
|
|
393
|
+
blockedDefaultTier: false,
|
|
394
|
+
});
|
|
302
395
|
}
|
|
303
396
|
|
|
304
397
|
/**
|
|
305
398
|
* Which tier applies, and where it came from.
|
|
306
399
|
*
|
|
307
400
|
* An explicitly requested tier that does not exist is an error rather than a
|
|
308
|
-
* fallback: the
|
|
401
|
+
* fallback: the caller asked for a specific policy, and quietly running a
|
|
309
402
|
* different model than the one it selected is worse than refusing.
|
|
403
|
+
*
|
|
404
|
+
* Precedence is `call > frontmatter > default`. A tier named per dispatch is
|
|
405
|
+
* current policy; an agent's own `tier:` is the older, weaker statement of the
|
|
406
|
+
* same thing. This holds for a workflow script exactly as it does for the host
|
|
407
|
+
* model — both are orchestrators routing one call.
|
|
408
|
+
*
|
|
409
|
+
* A `requireTier` spawn gets one extra step after the configured default: the
|
|
410
|
+
* shipped fallback. It is last because it is the only step the user did not
|
|
411
|
+
* write, and it is reachable only from a caller that has no parent model to
|
|
412
|
+
* fall back to.
|
|
413
|
+
*
|
|
414
|
+
* Exported because the managed-spawn path needs the tier *key* before the
|
|
415
|
+
* runner resolves — a tombstone and the lifecycle events carry it as a label.
|
|
416
|
+
* That caller asks this function rather than rebuilding the same three-step
|
|
417
|
+
* fallback beside it, for the reason given on {@link agentTierApplies}: a
|
|
418
|
+
* second copy of a precedence is only ever a way for the two to disagree.
|
|
310
419
|
*/
|
|
311
|
-
function
|
|
312
|
-
input:
|
|
313
|
-
settings: AgentTiersSettings,
|
|
420
|
+
export function selectAgentTier(
|
|
421
|
+
input: TierSelectionInput,
|
|
422
|
+
settings: AgentTiersSettings = agentTiersSettings,
|
|
314
423
|
): { tier: string; source: AgentTierSource } | undefined {
|
|
315
424
|
const requested = input.requestedTier;
|
|
316
425
|
if (requested !== undefined) {
|
|
317
|
-
if (!
|
|
426
|
+
if (!isManagedAgentTier(requested)) {
|
|
318
427
|
throw new AgentTierError(
|
|
319
428
|
`Invalid agent tier key. Keys are non-empty, contain no whitespace, and are at most ` +
|
|
320
429
|
`${MAX_AGENT_TIER_KEY_LENGTH} characters. Available tiers: ${tierKeyList(settings)}`,
|
|
@@ -330,7 +439,29 @@ function selectTier(
|
|
|
330
439
|
throw new AgentTierError("agentTiers.defaultTier is blocked by malformed configuration");
|
|
331
440
|
}
|
|
332
441
|
if (settings.defaultTier !== undefined) return { tier: settings.defaultTier, source: "default" };
|
|
333
|
-
|
|
442
|
+
// Only a spawn that may not inherit the parent reaches the shipped fallback.
|
|
443
|
+
if (input.requireTier !== true) return undefined;
|
|
444
|
+
const fallback = managedDefaultAgentTier(settings);
|
|
445
|
+
return fallback === undefined ? undefined : { tier: fallback, source: "default" };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Whether `resolveAgentTier` will select a tier for this spawn.
|
|
450
|
+
*
|
|
451
|
+
* The callers that pre-compute a legacy model or thinking value need this
|
|
452
|
+
* before the runner resolves: a selected tier owns both outright, so
|
|
453
|
+
* pre-resolving one would hand the runner a value it is about to discard, and a
|
|
454
|
+
* scope check against a model that never runs. Implemented by asking
|
|
455
|
+
* `selectAgentTier` itself rather than restating its precedence, so the two cannot
|
|
456
|
+
* drift. A fail-closed condition counts as "applies": the tier path owns the
|
|
457
|
+
* refusal, and the legacy path must not quietly answer in its place.
|
|
458
|
+
*/
|
|
459
|
+
export function agentTierApplies(input: TierSelectionInput): boolean {
|
|
460
|
+
try {
|
|
461
|
+
return selectAgentTier(input, input.settings ?? agentTiersSettings) !== undefined;
|
|
462
|
+
} catch {
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
334
465
|
}
|
|
335
466
|
|
|
336
467
|
function describeSource(source: AgentTierSource, agentName: string | undefined): string {
|
|
@@ -349,12 +480,14 @@ function describeSource(source: AgentTierSource, agentName: string | undefined):
|
|
|
349
480
|
*
|
|
350
481
|
* Returns `undefined` fields and no snapshot when no tier applies at all, which
|
|
351
482
|
* is how a workspace that has configured none keeps its previous behavior: the
|
|
352
|
-
* caller then falls back to
|
|
353
|
-
* and finally
|
|
483
|
+
* caller then falls back to a programmatic model option, the configured
|
|
484
|
+
* `defaultModel`, and finally the parent session. A `requireTier` spawn cannot
|
|
485
|
+
* take that path, so it reaches the shipped fallback instead — and fails closed
|
|
486
|
+
* when the user has suppressed that too.
|
|
354
487
|
*/
|
|
355
488
|
export function resolveAgentTier(input: ResolveAgentTierInput): AgentTierResolution {
|
|
356
489
|
const settings = input.settings ?? agentTiersSettings;
|
|
357
|
-
const selected =
|
|
490
|
+
const selected = selectAgentTier(input, settings);
|
|
358
491
|
if (!selected) return {};
|
|
359
492
|
|
|
360
493
|
const { tier, source } = selected;
|
|
@@ -515,3 +648,36 @@ export function buildAgentTierParameterDescription(settings: AgentTiersSettings
|
|
|
515
648
|
" A tier overrides the agent's default. Unknown tiers are rejected rather than substituted."
|
|
516
649
|
);
|
|
517
650
|
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The catalogue as a managed peer needs to see it.
|
|
654
|
+
*
|
|
655
|
+
* Protocol-shaped on purpose: no settings-file details, no descriptions, no
|
|
656
|
+
* host paths — only what decides how a tier resolves, so a peer can tell
|
|
657
|
+
* whether a journaled call would still resolve the same way. `defaultTier` is
|
|
658
|
+
* the *managed* default, shipped fallback included: every consumer of this
|
|
659
|
+
* snapshot is a managed caller, and publishing the bare configured value would
|
|
660
|
+
* make its replay identity disagree with what the host actually selects.
|
|
661
|
+
*
|
|
662
|
+
* Both sorts use the default code-unit order rather than `localeCompare`.
|
|
663
|
+
* `blockedProfiles` is an array, so its order reaches the fingerprint, and a
|
|
664
|
+
* locale-dependent comparator would let the same catalogue hash differently on
|
|
665
|
+
* two machines — or on one machine after its locale changed. Object keys are
|
|
666
|
+
* canonicalized again by `canonicalizeRoutingPolicy`; sorting them here only
|
|
667
|
+
* keeps the wire payload readable.
|
|
668
|
+
*/
|
|
669
|
+
export function getRoutingPolicySnapshot(
|
|
670
|
+
settings: AgentTiersSettings = agentTiersSettings,
|
|
671
|
+
): ManagedRoutingPolicySnapshot {
|
|
672
|
+
const policy: ManagedRoutingPolicy = {
|
|
673
|
+
defaultTier: managedDefaultAgentTier(settings) ?? null,
|
|
674
|
+
profiles: Object.fromEntries(
|
|
675
|
+
Object.entries(settings.profiles ?? {})
|
|
676
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
677
|
+
.map(([key, profile]) => [key, { model: profile.model, thinking: profile.thinking }]),
|
|
678
|
+
),
|
|
679
|
+
blockedProfiles: [...(settings.blockedProfiles ?? [])].sort(),
|
|
680
|
+
blockedDefaultTier: settings.blockedDefaultTier === true,
|
|
681
|
+
};
|
|
682
|
+
return { policy, fingerprint: routingPolicyFingerprint(policy) };
|
|
683
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
CHILD_CONTEXT_CAPABILITY,
|
|
11
|
+
type ManagedRoutingPolicySnapshot,
|
|
11
12
|
PROTOCOL_CAPABILITIES,
|
|
12
13
|
PROTOCOL_VERSION,
|
|
13
14
|
parseManagedSpawnRequest,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
export { CHILD_CONTEXT_CAPABILITY, PROTOCOL_CAPABILITIES, PROTOCOL_VERSION };
|
|
17
18
|
|
|
18
19
|
import type { ManagedSpawnPolicy, ManagedSpawnRequest, ManagedSpawnResult } from "./agent-manager.js";
|
|
20
|
+
import { getRoutingPolicySnapshot } from "./agent-tiers.js";
|
|
19
21
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
20
22
|
import type { AgentOwner } from "./types.js";
|
|
21
23
|
|
|
@@ -49,6 +51,8 @@ export interface RpcDeps {
|
|
|
49
51
|
pi: unknown;
|
|
50
52
|
getCtx: () => unknown | undefined;
|
|
51
53
|
manager: SpawnCapable;
|
|
54
|
+
/** Override for focused consumers/tests; production reads live policy state. */
|
|
55
|
+
getRoutingPolicy?: () => ManagedRoutingPolicySnapshot;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
export interface RpcHandle {
|
|
@@ -164,10 +168,12 @@ function handleRpc(
|
|
|
164
168
|
*/
|
|
165
169
|
export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
166
170
|
const { events, pi, getCtx, manager } = deps;
|
|
171
|
+
const getRoutingPolicy = deps.getRoutingPolicy ?? getRoutingPolicySnapshot;
|
|
167
172
|
|
|
168
173
|
const unsubPing = handleRpc(events, "subagents:rpc:ping", () => ({
|
|
169
174
|
version: PROTOCOL_VERSION,
|
|
170
175
|
capabilities: PROTOCOL_CAPABILITIES,
|
|
176
|
+
routingPolicy: getRoutingPolicy(),
|
|
171
177
|
}));
|
|
172
178
|
|
|
173
179
|
const unsubSpawn = handleRpc(events, "subagents:rpc:spawn", (params) => {
|
package/src/default-agents.ts
CHANGED
|
@@ -34,12 +34,12 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
|
|
34
34
|
builtinToolNames: READ_ONLY_TOOLS,
|
|
35
35
|
extensions: true,
|
|
36
36
|
skills: true,
|
|
37
|
-
// Runs on the shipped `
|
|
37
|
+
// Runs on the shipped `low` agent tier (model inherit, low thinking) so
|
|
38
38
|
// read-only search does not inherit the parent session's most expensive
|
|
39
39
|
// model on machines that never configured agentTiers. The tier is the
|
|
40
|
-
// policy: point `
|
|
40
|
+
// policy: point `low` at a cheap model in subagents.json and Explore
|
|
41
41
|
// follows without touching agent files.
|
|
42
|
-
agentTier: "
|
|
42
|
+
agentTier: "low",
|
|
43
43
|
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
|
44
44
|
You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
|
45
45
|
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
|