@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/nested-tools.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import {
|
|
3
3
|
type AgentSession,
|
|
4
4
|
defineTool,
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Type } from "@sinclair/typebox";
|
|
10
10
|
import { abortable } from "./abortable.js";
|
|
11
|
-
import { buildAgentTierParameterDescription } from "./agent-tiers.js";
|
|
11
|
+
import { buildAgentTierParameterDescription, getAgentTiersSettings } from "./agent-tiers.js";
|
|
12
12
|
import {
|
|
13
13
|
buildAgentRegistry,
|
|
14
14
|
getAgentConfigIn,
|
|
@@ -53,7 +53,7 @@ interface NestedSpawnOptions {
|
|
|
53
53
|
description: string;
|
|
54
54
|
/** User-named model tier; resolved by the runner like every other spawn path. */
|
|
55
55
|
agentTier?: string;
|
|
56
|
-
model?: Model<
|
|
56
|
+
model?: Model<Api>;
|
|
57
57
|
maxTurns?: number;
|
|
58
58
|
isolated?: boolean;
|
|
59
59
|
inheritContext?: boolean;
|
|
@@ -224,9 +224,15 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
const config = getAgentConfigIn(registry, resolvedType);
|
|
227
|
-
const invocation = resolveAgentInvocationConfig(config,
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
const invocation = resolveAgentInvocationConfig(config, {
|
|
228
|
+
...params,
|
|
229
|
+
agentTiers: getAgentTiersSettings(),
|
|
230
|
+
});
|
|
231
|
+
// Leave model/thinking unset whenever an Agent tier may apply. runAgent
|
|
232
|
+
// is the sole final resolver; pre-resolving here would let a parent or
|
|
233
|
+
// legacy field bypass the selected profile.
|
|
234
|
+
let model: Model<Api> | undefined = invocation.agentTierSelected ? undefined : ctx.model;
|
|
235
|
+
if (!invocation.agentTierSelected && invocation.modelInput) {
|
|
230
236
|
const resolvedModel = resolveModel(invocation.modelInput, ctx.modelRegistry);
|
|
231
237
|
if (typeof resolvedModel === "string") {
|
|
232
238
|
if (invocation.modelFromParams) return textResult(resolvedModel, true);
|
|
@@ -236,17 +242,19 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
// Same scopeModels policy as the top-level Agent tool — a nested spawn
|
|
239
|
-
// must not escape the allowlist. A
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
245
|
+
// must not escape the allowlist. A tier's final model is scope-checked by
|
|
246
|
+
// runAgent after resolution; checking it here would duplicate resolution.
|
|
247
|
+
if (!invocation.agentTierSelected) {
|
|
248
|
+
const scopeVerdict = checkModelScope({
|
|
249
|
+
model,
|
|
250
|
+
cwd: context.configCwd,
|
|
251
|
+
modelRegistry: ctx.modelRegistry,
|
|
252
|
+
callerSupplied: invocation.modelFromParams,
|
|
253
|
+
agentLabel: config?.displayName ?? resolvedType,
|
|
254
|
+
modelInput: invocation.modelInput,
|
|
255
|
+
});
|
|
256
|
+
if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message, true);
|
|
257
|
+
}
|
|
250
258
|
|
|
251
259
|
// The whole branch shares the root session's transcript directory; read it
|
|
252
260
|
// off the owning parent rather than this child session's own id.
|
|
@@ -254,18 +262,23 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
254
262
|
const childDepth = context.depth + 1;
|
|
255
263
|
const options: NestedSpawnOptions = {
|
|
256
264
|
description: params.description,
|
|
265
|
+
// Only the caller's own tier. An agent's frontmatter tier is read by
|
|
266
|
+
// `resolveAgentTier` itself, and passing it here would relabel its
|
|
267
|
+
// source as "call" — which turns a scopeModels warning into a refusal
|
|
268
|
+
// and blames the caller for a choice the agent file made.
|
|
257
269
|
agentTier: invocation.requestedAgentTier,
|
|
258
|
-
model,
|
|
270
|
+
model: invocation.agentTierSelected ? undefined : model,
|
|
259
271
|
maxTurns: invocation.maxTurns,
|
|
260
272
|
isolated: invocation.isolated,
|
|
261
273
|
inheritContext: invocation.inheritContext,
|
|
262
|
-
thinkingLevel: invocation.thinking,
|
|
274
|
+
thinkingLevel: invocation.agentTierSelected ? undefined : invocation.thinking,
|
|
263
275
|
isolation: invocation.isolation,
|
|
264
276
|
invocation: {
|
|
265
|
-
|
|
277
|
+
// Display only: the effective tier, whoever named it.
|
|
278
|
+
...(invocation.requestedAgentTier === undefined && config?.agentTier === undefined
|
|
266
279
|
? {}
|
|
267
|
-
: { agentTier: invocation.requestedAgentTier }),
|
|
268
|
-
thinking: invocation.thinking,
|
|
280
|
+
: { agentTier: invocation.requestedAgentTier ?? config?.agentTier }),
|
|
281
|
+
thinking: invocation.agentTierSelected ? undefined : invocation.thinking,
|
|
269
282
|
maxTurns: invocation.maxTurns,
|
|
270
283
|
isolated: invocation.isolated,
|
|
271
284
|
inheritContext: invocation.inheritContext,
|
package/src/schedule.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface NewJobInput {
|
|
|
40
40
|
schedule: string;
|
|
41
41
|
subagent_type: SubagentType;
|
|
42
42
|
prompt: string;
|
|
43
|
+
/** User-named Agent tier preserved until fire time. */
|
|
44
|
+
tier?: string;
|
|
43
45
|
model?: string;
|
|
44
46
|
thinking?: ThinkingLevel;
|
|
45
47
|
max_turns?: number;
|
|
@@ -103,6 +105,7 @@ export class SubagentScheduler {
|
|
|
103
105
|
intervalMs: detected.intervalMs,
|
|
104
106
|
subagent_type: input.subagent_type,
|
|
105
107
|
prompt: input.prompt,
|
|
108
|
+
tier: input.tier,
|
|
106
109
|
model: input.model,
|
|
107
110
|
thinking: input.thinking,
|
|
108
111
|
max_turns: input.max_turns,
|
package/src/settings.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { basename, dirname, join } from "node:path";
|
|
8
8
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import
|
|
9
|
+
import { isManagedAgentTier, MAX_AGENT_TIER_PROFILES } from "@signalridge/pi-subagents-protocol";
|
|
10
10
|
// Imported only for the applySettings fallback so a persisted defaultToolTimeoutMs
|
|
11
11
|
// takes effect even before the host wires the new applier — avoids needing to
|
|
12
12
|
// edit index.ts in the same change.
|
|
@@ -22,17 +22,15 @@ const VALID_AGENT_MENTION_MODES: ReadonlySet<string> = new Set(AGENT_MENTION_MOD
|
|
|
22
22
|
/** A tier's thinking value: a level, or `inherit` to keep the parent's. */
|
|
23
23
|
export type TierThinking = ThinkingLevel | "inherit";
|
|
24
24
|
|
|
25
|
-
/** Historical name for {@link TierThinking}, kept for the workflow tier types. */
|
|
26
|
-
export type WorkflowThinking = TierThinking;
|
|
27
|
-
|
|
28
25
|
/**
|
|
29
26
|
* One user-named (model, thinking) pair for ordinary subagent spawns.
|
|
30
27
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* tier
|
|
28
|
+
* The key space is whatever the user chose. `description` is what the host
|
|
29
|
+
* agent reads in the tool description to decide between tiers, so it is prose
|
|
30
|
+
* about the job the tier is for, not about the model.
|
|
31
|
+
*
|
|
32
|
+
* Managed workflow calls name a tier from this same catalogue; there is no
|
|
33
|
+
* second workflow-tier vocabulary.
|
|
36
34
|
*/
|
|
37
35
|
export interface AgentTierProfile {
|
|
38
36
|
/** `inherit` keeps the parent model; other values are provider/model references. */
|
|
@@ -47,6 +45,15 @@ export interface AgentTierProfile {
|
|
|
47
45
|
export interface AgentTiersSettings {
|
|
48
46
|
/** Applied when neither the caller nor the agent names a tier. */
|
|
49
47
|
defaultTier?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The user chose to have no default at all.
|
|
50
|
+
*
|
|
51
|
+
* Distinct from an absent `defaultTier`, which still lets a managed workflow
|
|
52
|
+
* call reach the shipped fallback in `agent-tiers.ts`. This is how a user
|
|
53
|
+
* withdraws that fallback as well, the same way `blockedProfiles` says "do not
|
|
54
|
+
* substitute a shipped profile".
|
|
55
|
+
*/
|
|
56
|
+
noDefaultTier?: boolean;
|
|
50
57
|
/** A configured entry replaces the complete entry inherited from global settings. */
|
|
51
58
|
profiles?: Record<string, AgentTierProfile>;
|
|
52
59
|
/** Tombstones for malformed explicit profiles; not a user policy field. */
|
|
@@ -55,41 +62,11 @@ export interface AgentTiersSettings {
|
|
|
55
62
|
blockedDefaultTier?: boolean;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
export interface WorkflowTierProfile {
|
|
59
|
-
/** `inherit` keeps the parent model; other values are provider/model references. */
|
|
60
|
-
model: string;
|
|
61
|
-
/** `inherit` keeps the parent Thinking level; other values are clamped natively. */
|
|
62
|
-
thinking: WorkflowThinking;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Workflow policy configured by the user and resolved only by pi-subagents. */
|
|
66
|
-
export interface WorkflowSettings {
|
|
67
|
-
/** Optional default used when a managed workflow task omits its tier. */
|
|
68
|
-
defaultTier?: WorkflowTier;
|
|
69
|
-
/** A configured entry replaces the complete entry inherited from global settings. */
|
|
70
|
-
tiers?: Partial<Record<WorkflowTier, WorkflowTierProfile>>;
|
|
71
|
-
/** Persistence tombstones for malformed explicit tier entries; not a user policy field. */
|
|
72
|
-
blockedTiers?: WorkflowTier[];
|
|
73
|
-
/** Persistence tombstone for a malformed defaultTier; not a user policy field. */
|
|
74
|
-
blockedDefaultTier?: boolean;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
export const DEFAULT_WORKFLOW_TIER_PROFILES: Readonly<Record<WorkflowTier, WorkflowTierProfile>> = {
|
|
79
|
-
// Defaults are provider-neutral. Users may pin a tier to any concrete
|
|
80
|
-
// provider/model in the same complete profile, but the extension must not
|
|
81
|
-
// silently choose a vendor-specific model on a new machine.
|
|
82
|
-
small: { model: "inherit", thinking: "low" },
|
|
83
|
-
medium: { model: "inherit", thinking: "medium" },
|
|
84
|
-
large: { model: "inherit", thinking: "high" },
|
|
85
|
-
};
|
|
86
65
|
export interface SubagentsSettings {
|
|
87
|
-
/** Semantic model-plus-thinking profiles used by workflow-owned spawns. */
|
|
88
|
-
workflow?: WorkflowSettings;
|
|
89
66
|
/**
|
|
90
|
-
* User-named model tiers
|
|
91
|
-
*
|
|
92
|
-
*
|
|
67
|
+
* User-named model tiers. The one tier catalogue: ordinary Agent spawns, the
|
|
68
|
+
* scheduler, nested delegation, and managed `pi-workflows` calls all name a
|
|
69
|
+
* key from here. See `agent-tiers.ts` for resolution and precedence.
|
|
93
70
|
*/
|
|
94
71
|
agentTiers?: AgentTiersSettings;
|
|
95
72
|
/**
|
|
@@ -304,9 +281,7 @@ export interface SettingsAppliers {
|
|
|
304
281
|
setMaxSubagentDepth: (n: number) => void;
|
|
305
282
|
setMaxSubagentSpawnsPerBranch: (n: number) => void;
|
|
306
283
|
setFallbackSubagent: (v: string | undefined) => void;
|
|
307
|
-
/** Optional because non-runtime settings tests and consumers need not apply
|
|
308
|
-
setWorkflow?: (settings: WorkflowSettings) => void;
|
|
309
|
-
/** Optional for the same reason as `setWorkflow`. */
|
|
284
|
+
/** Optional because non-runtime settings tests and consumers need not apply tier state. */
|
|
310
285
|
setAgentTiers?: (settings: AgentTiersSettings) => void;
|
|
311
286
|
}
|
|
312
287
|
|
|
@@ -332,12 +307,7 @@ export const TIER_THINKING_LEVELS: readonly TierThinking[] = [
|
|
|
332
307
|
"max",
|
|
333
308
|
];
|
|
334
309
|
const VALID_THINKING_LEVELS: ReadonlySet<string> = new Set(TIER_THINKING_LEVELS);
|
|
335
|
-
const WORKFLOW_TIER_NAMES: readonly WorkflowTier[] = ["small", "medium", "large"];
|
|
336
310
|
const MAX_MODEL_REFERENCE_LENGTH = 512;
|
|
337
|
-
/** Mirrors MAX_AGENT_TIER_KEY_LENGTH in agent-tiers.ts; duplicated to keep settings dependency-free. */
|
|
338
|
-
const MAX_AGENT_TIER_KEY_LENGTH = 64;
|
|
339
|
-
/** Bounds a hand-edited config; far above any realistic number of tiers. */
|
|
340
|
-
const MAX_AGENT_TIER_PROFILES = 64;
|
|
341
311
|
const MAX_AGENT_TIER_DESCRIPTION_LENGTH = 512;
|
|
342
312
|
|
|
343
313
|
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
@@ -377,60 +347,10 @@ export function isModelReference(value: unknown): value is string {
|
|
|
377
347
|
return slash > 0 && slash < model.length - 1;
|
|
378
348
|
}
|
|
379
349
|
|
|
380
|
-
function validThinkingLevel(value: unknown): value is
|
|
350
|
+
function validThinkingLevel(value: unknown): value is TierThinking {
|
|
381
351
|
return typeof value === "string" && VALID_THINKING_LEVELS.has(value);
|
|
382
352
|
}
|
|
383
353
|
|
|
384
|
-
/**
|
|
385
|
-
* A profile is all-or-nothing. In particular, never turn `{ model: ... }` into
|
|
386
|
-
* a profile whose thinking silently inherits from the parent: that would make a
|
|
387
|
-
* typo in a policy file look like an intentional policy choice.
|
|
388
|
-
*/
|
|
389
|
-
function sanitizeWorkflowProfile(raw: unknown): WorkflowTierProfile | undefined {
|
|
390
|
-
if (!isRecord(raw)) return undefined;
|
|
391
|
-
const keys = Object.keys(raw);
|
|
392
|
-
if (keys.some((key) => key !== "model" && key !== "thinking")) return undefined;
|
|
393
|
-
if (!Object.hasOwn(raw, "model") || !Object.hasOwn(raw, "thinking")) return undefined;
|
|
394
|
-
if (!isModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
|
|
395
|
-
return { model: raw.model.trim(), thinking: raw.thinking };
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function sanitizeWorkflow(raw: unknown): WorkflowSettings | undefined {
|
|
399
|
-
if (!isRecord(raw)) return undefined;
|
|
400
|
-
const out: WorkflowSettings = {};
|
|
401
|
-
if (isWorkflowTierValue(raw.defaultTier)) out.defaultTier = raw.defaultTier;
|
|
402
|
-
if (Object.hasOwn(raw, "defaultTier") && !isWorkflowTierValue(raw.defaultTier)) out.blockedDefaultTier = true;
|
|
403
|
-
if (raw.blockedDefaultTier === true) out.blockedDefaultTier = true;
|
|
404
|
-
if (Object.hasOwn(raw, "blockedTiers")) {
|
|
405
|
-
const rawBlocked = raw.blockedTiers;
|
|
406
|
-
const blocked = Array.isArray(rawBlocked) && rawBlocked.every(isWorkflowTierValue)
|
|
407
|
-
? [...new Set(rawBlocked)]
|
|
408
|
-
: [...WORKFLOW_TIER_NAMES];
|
|
409
|
-
if (blocked.length > 0) out.blockedTiers = blocked;
|
|
410
|
-
}
|
|
411
|
-
const tiersValue = raw.tiers;
|
|
412
|
-
if (isRecord(tiersValue)) {
|
|
413
|
-
const tiers: Partial<Record<WorkflowTier, WorkflowTierProfile>> = {};
|
|
414
|
-
for (const tier of WORKFLOW_TIER_NAMES) {
|
|
415
|
-
if (!Object.hasOwn(tiersValue, tier)) continue;
|
|
416
|
-
const profile = sanitizeWorkflowProfile(tiersValue[tier]);
|
|
417
|
-
if (profile) tiers[tier] = profile;
|
|
418
|
-
}
|
|
419
|
-
if (Object.keys(tiers).length > 0) out.tiers = tiers;
|
|
420
|
-
}
|
|
421
|
-
return out.defaultTier !== undefined || out.tiers !== undefined || out.blockedTiers !== undefined || out.blockedDefaultTier !== undefined ? out : undefined;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function isAgentTierKey(value: unknown): value is string {
|
|
425
|
-
return (
|
|
426
|
-
typeof value === "string" &&
|
|
427
|
-
value.length > 0 &&
|
|
428
|
-
value.length <= MAX_AGENT_TIER_KEY_LENGTH &&
|
|
429
|
-
value.trim() === value &&
|
|
430
|
-
!/\s/u.test(value)
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
354
|
/**
|
|
435
355
|
* A profile is all-or-nothing, for the same reason a workflow profile is: half
|
|
436
356
|
* a profile with an implied `inherit` makes a typo look like a policy choice.
|
|
@@ -461,23 +381,25 @@ function sanitizeAgentTierProfile(raw: unknown): AgentTierProfile | undefined {
|
|
|
461
381
|
function sanitizeAgentTiers(raw: unknown): AgentTiersSettings | undefined {
|
|
462
382
|
if (!isRecord(raw)) return undefined;
|
|
463
383
|
const out: AgentTiersSettings = {};
|
|
464
|
-
if (
|
|
465
|
-
if (Object.hasOwn(raw, "defaultTier") && !
|
|
384
|
+
if (isManagedAgentTier(raw.defaultTier)) out.defaultTier = raw.defaultTier;
|
|
385
|
+
if (Object.hasOwn(raw, "defaultTier") && !isManagedAgentTier(raw.defaultTier)) out.blockedDefaultTier = true;
|
|
466
386
|
if (raw.blockedDefaultTier === true) out.blockedDefaultTier = true;
|
|
387
|
+
if (raw.noDefaultTier === true && out.defaultTier === undefined) out.noDefaultTier = true;
|
|
467
388
|
if (Array.isArray(raw.blockedProfiles)) {
|
|
468
|
-
const blocked = [...new Set(raw.blockedProfiles.filter(
|
|
389
|
+
const blocked = [...new Set(raw.blockedProfiles.filter(isManagedAgentTier))];
|
|
469
390
|
if (blocked.length > 0) out.blockedProfiles = blocked;
|
|
470
391
|
}
|
|
471
392
|
if (isRecord(raw.profiles)) {
|
|
472
393
|
const profiles: Record<string, AgentTierProfile> = {};
|
|
473
394
|
for (const key of Object.keys(raw.profiles).slice(0, MAX_AGENT_TIER_PROFILES)) {
|
|
474
|
-
if (!
|
|
395
|
+
if (!isManagedAgentTier(key)) continue;
|
|
475
396
|
const profile = sanitizeAgentTierProfile(raw.profiles[key]);
|
|
476
397
|
if (profile) profiles[key] = profile;
|
|
477
398
|
}
|
|
478
399
|
if (Object.keys(profiles).length > 0) out.profiles = profiles;
|
|
479
400
|
}
|
|
480
401
|
return out.defaultTier !== undefined ||
|
|
402
|
+
out.noDefaultTier !== undefined ||
|
|
481
403
|
out.profiles !== undefined ||
|
|
482
404
|
out.blockedProfiles !== undefined ||
|
|
483
405
|
out.blockedDefaultTier !== undefined
|
|
@@ -485,9 +407,25 @@ function sanitizeAgentTiers(raw: unknown): AgentTiersSettings | undefined {
|
|
|
485
407
|
: undefined;
|
|
486
408
|
}
|
|
487
409
|
|
|
488
|
-
|
|
489
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Announce the retired `workflow` key once per process.
|
|
412
|
+
*
|
|
413
|
+
* `sanitize` runs on both settings files on every load and again on every
|
|
414
|
+
* project write, so warning at the call site would repeat the same line several
|
|
415
|
+
* times per session and once more each time the user saves Settings. The key is
|
|
416
|
+
* one fact about the configuration, not an event; state it once.
|
|
417
|
+
*/
|
|
418
|
+
let warnedRetiredWorkflowKey = false;
|
|
419
|
+
function warnRetiredWorkflowKey(): void {
|
|
420
|
+
if (warnedRetiredWorkflowKey) return;
|
|
421
|
+
warnedRetiredWorkflowKey = true;
|
|
422
|
+
console.warn(
|
|
423
|
+
'[pi-subagents] the "workflow" settings key is retired and ignored; ' +
|
|
424
|
+
'managed workflow calls name an "agentTiers" key directly. ' +
|
|
425
|
+
'Move any routing you had there to "agentTiers" (its "defaultTier" replaces "workflow.defaultTier").',
|
|
426
|
+
);
|
|
490
427
|
}
|
|
428
|
+
|
|
491
429
|
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
|
|
492
430
|
function sanitize(raw: unknown): SubagentsSettings {
|
|
493
431
|
if (!raw || typeof raw !== "object") return {};
|
|
@@ -597,8 +535,9 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
597
535
|
out.worktreeIsolation = r.worktreeIsolation;
|
|
598
536
|
}
|
|
599
537
|
|
|
600
|
-
|
|
601
|
-
|
|
538
|
+
// `workflow` is retired: workflow calls now name a key from `agentTiers`.
|
|
539
|
+
// Warn by name rather than silently dropping a policy the user still believes in.
|
|
540
|
+
if (Object.hasOwn(r, "workflow")) warnRetiredWorkflowKey();
|
|
602
541
|
const agentTiers = sanitizeAgentTiers(r.agentTiers);
|
|
603
542
|
if (agentTiers) out.agentTiers = agentTiers;
|
|
604
543
|
return out;
|
|
@@ -614,21 +553,9 @@ function projectPath(cwd: string): string {
|
|
|
614
553
|
|
|
615
554
|
/**
|
|
616
555
|
* Read a settings file. Missing file is silent (returns `{}`). A file that
|
|
617
|
-
* exists but can't be parsed emits a warning to stderr and blocks
|
|
556
|
+
* exists but can't be parsed emits a warning to stderr and blocks Agent-tier
|
|
618
557
|
* policy so users aren't silently reverted to defaults.
|
|
619
558
|
*/
|
|
620
|
-
interface WorkflowSource {
|
|
621
|
-
present: boolean;
|
|
622
|
-
object: boolean;
|
|
623
|
-
defaultTierPresent: boolean;
|
|
624
|
-
defaultTier?: WorkflowTier;
|
|
625
|
-
tiersPresent: boolean;
|
|
626
|
-
tiersObject: boolean;
|
|
627
|
-
tierEntries: Partial<Record<WorkflowTier, WorkflowTierProfile | undefined>>;
|
|
628
|
-
blockedTiers: WorkflowTier[];
|
|
629
|
-
blockedDefaultTier: boolean;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
559
|
/**
|
|
633
560
|
* Presence of each agent-tier entry in one file, kept separate from the
|
|
634
561
|
* sanitized value so the merge can tell "the project did not mention this
|
|
@@ -640,6 +567,7 @@ interface AgentTiersSource {
|
|
|
640
567
|
object: boolean;
|
|
641
568
|
defaultTierPresent: boolean;
|
|
642
569
|
defaultTier?: string;
|
|
570
|
+
noDefaultTier: boolean;
|
|
643
571
|
profilesPresent: boolean;
|
|
644
572
|
profilesObject: boolean;
|
|
645
573
|
profileEntries: Record<string, AgentTierProfile | undefined>;
|
|
@@ -649,29 +577,16 @@ interface AgentTiersSource {
|
|
|
649
577
|
|
|
650
578
|
interface ReadSettings {
|
|
651
579
|
settings: SubagentsSettings;
|
|
652
|
-
workflow: WorkflowSource;
|
|
653
580
|
agentTiers: AgentTiersSource;
|
|
654
581
|
}
|
|
655
582
|
|
|
656
|
-
function emptyWorkflowSource(): WorkflowSource {
|
|
657
|
-
return {
|
|
658
|
-
present: false,
|
|
659
|
-
object: false,
|
|
660
|
-
defaultTierPresent: false,
|
|
661
|
-
blockedDefaultTier: false,
|
|
662
|
-
tiersPresent: false,
|
|
663
|
-
tiersObject: false,
|
|
664
|
-
tierEntries: {},
|
|
665
|
-
blockedTiers: [],
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
|
|
669
583
|
function emptyAgentTiersSource(): AgentTiersSource {
|
|
670
584
|
return {
|
|
671
585
|
present: false,
|
|
672
586
|
object: false,
|
|
673
587
|
defaultTierPresent: false,
|
|
674
588
|
blockedDefaultTier: false,
|
|
589
|
+
noDefaultTier: false,
|
|
675
590
|
profilesPresent: false,
|
|
676
591
|
profilesObject: false,
|
|
677
592
|
profileEntries: {},
|
|
@@ -692,17 +607,18 @@ function agentTiersSource(raw: unknown): AgentTiersSource {
|
|
|
692
607
|
object: true,
|
|
693
608
|
defaultTierPresent: Object.hasOwn(value, "defaultTier"),
|
|
694
609
|
blockedDefaultTier:
|
|
695
|
-
(Object.hasOwn(value, "defaultTier") && !
|
|
696
|
-
...(
|
|
610
|
+
(Object.hasOwn(value, "defaultTier") && !isManagedAgentTier(value.defaultTier)) || value.blockedDefaultTier === true,
|
|
611
|
+
...(isManagedAgentTier(value.defaultTier) ? { defaultTier: value.defaultTier } : {}),
|
|
697
612
|
profilesPresent: Object.hasOwn(value, "profiles"),
|
|
613
|
+
noDefaultTier: value.noDefaultTier === true,
|
|
698
614
|
profilesObject: isRecord(value.profiles),
|
|
699
615
|
profileEntries: {},
|
|
700
|
-
blockedProfiles: Array.isArray(value.blockedProfiles) ? value.blockedProfiles.filter(
|
|
616
|
+
blockedProfiles: Array.isArray(value.blockedProfiles) ? value.blockedProfiles.filter(isManagedAgentTier) : [],
|
|
701
617
|
};
|
|
702
618
|
|
|
703
619
|
if (isRecord(value.profiles)) {
|
|
704
620
|
for (const key of Object.keys(value.profiles).slice(0, MAX_AGENT_TIER_PROFILES)) {
|
|
705
|
-
if (!
|
|
621
|
+
if (!isManagedAgentTier(key)) continue;
|
|
706
622
|
const profile = sanitizeAgentTierProfile(value.profiles[key]);
|
|
707
623
|
source.profileEntries[key] = profile;
|
|
708
624
|
if (!profile) source.blockedProfiles.push(key);
|
|
@@ -739,8 +655,13 @@ function mergeAgentTierSources(
|
|
|
739
655
|
if (project.defaultTierPresent) {
|
|
740
656
|
if (project.defaultTier !== undefined) merged.defaultTier = project.defaultTier;
|
|
741
657
|
else merged.blockedDefaultTier = true;
|
|
658
|
+
} else if (project.noDefaultTier) {
|
|
659
|
+
// The project said "none" explicitly; that outranks a global default and
|
|
660
|
+
// stops the shipped one from filling the gap.
|
|
661
|
+
merged.noDefaultTier = true;
|
|
742
662
|
} else {
|
|
743
663
|
if (global.defaultTier !== undefined) merged.defaultTier = global.defaultTier;
|
|
664
|
+
else if (global.noDefaultTier) merged.noDefaultTier = true;
|
|
744
665
|
if (global.blockedDefaultTier || project.blockedDefaultTier) merged.blockedDefaultTier = true;
|
|
745
666
|
}
|
|
746
667
|
|
|
@@ -762,6 +683,7 @@ function mergeAgentTierSources(
|
|
|
762
683
|
}
|
|
763
684
|
continue;
|
|
764
685
|
}
|
|
686
|
+
if (blocked.has(key)) continue;
|
|
765
687
|
const inherited = global.profileEntries[key];
|
|
766
688
|
if (inherited) profiles[key] = inherited;
|
|
767
689
|
}
|
|
@@ -769,6 +691,7 @@ function mergeAgentTierSources(
|
|
|
769
691
|
if (Object.keys(profiles).length > 0) merged.profiles = profiles;
|
|
770
692
|
if (blocked.size > 0) merged.blockedProfiles = [...blocked].sort((a, b) => a.localeCompare(b));
|
|
771
693
|
return merged.defaultTier !== undefined ||
|
|
694
|
+
merged.noDefaultTier !== undefined ||
|
|
772
695
|
merged.profiles !== undefined ||
|
|
773
696
|
merged.blockedProfiles !== undefined ||
|
|
774
697
|
merged.blockedDefaultTier !== undefined
|
|
@@ -776,70 +699,18 @@ function mergeAgentTierSources(
|
|
|
776
699
|
: undefined;
|
|
777
700
|
}
|
|
778
701
|
|
|
779
|
-
/** Preserve invalid workflow entry presence so project policy cannot resurrect a global entry. */
|
|
780
|
-
function workflowSource(raw: unknown): WorkflowSource {
|
|
781
|
-
if (!isRecord(raw) || !Object.hasOwn(raw, "workflow")) return emptyWorkflowSource();
|
|
782
|
-
const value = raw.workflow;
|
|
783
|
-
if (!isRecord(value)) {
|
|
784
|
-
return {
|
|
785
|
-
...emptyWorkflowSource(),
|
|
786
|
-
present: true,
|
|
787
|
-
object: false,
|
|
788
|
-
blockedTiers: [...WORKFLOW_TIER_NAMES],
|
|
789
|
-
blockedDefaultTier: true,
|
|
790
|
-
};
|
|
791
|
-
}
|
|
792
|
-
const tiersValue = value.tiers;
|
|
793
|
-
const blockedTiers: WorkflowTier[] = [];
|
|
794
|
-
if (Object.hasOwn(value, "blockedTiers")) {
|
|
795
|
-
const rawBlocked = value.blockedTiers;
|
|
796
|
-
if (Array.isArray(rawBlocked) && rawBlocked.every(isWorkflowTierValue)) blockedTiers.push(...new Set(rawBlocked));
|
|
797
|
-
else blockedTiers.push(...WORKFLOW_TIER_NAMES);
|
|
798
|
-
}
|
|
799
|
-
const source: WorkflowSource = {
|
|
800
|
-
present: true,
|
|
801
|
-
object: true,
|
|
802
|
-
defaultTierPresent: Object.hasOwn(value, "defaultTier"),
|
|
803
|
-
blockedDefaultTier:
|
|
804
|
-
(Object.hasOwn(value, "defaultTier") && !isWorkflowTierValue(value.defaultTier)) || value.blockedDefaultTier === true,
|
|
805
|
-
...(isWorkflowTierValue(value.defaultTier) ? { defaultTier: value.defaultTier } : {}),
|
|
806
|
-
tiersPresent: Object.hasOwn(value, "tiers"),
|
|
807
|
-
tiersObject: isRecord(tiersValue),
|
|
808
|
-
tierEntries: {},
|
|
809
|
-
blockedTiers,
|
|
810
|
-
};
|
|
811
|
-
if (Object.hasOwn(value, "tiers") && !source.tiersObject) blockedTiers.push(...WORKFLOW_TIER_NAMES);
|
|
812
|
-
if (isRecord(tiersValue)) {
|
|
813
|
-
for (const tier of WORKFLOW_TIER_NAMES) {
|
|
814
|
-
if (!Object.hasOwn(tiersValue, tier)) continue;
|
|
815
|
-
const profile = sanitizeWorkflowProfile(tiersValue[tier]);
|
|
816
|
-
source.tierEntries[tier] = profile;
|
|
817
|
-
if (!profile) blockedTiers.push(tier);
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
source.blockedTiers = [...new Set(blockedTiers)];
|
|
821
|
-
return source;
|
|
822
|
-
}
|
|
823
|
-
|
|
824
702
|
function readSettingsFile(path: string): ReadSettings {
|
|
825
703
|
if (!existsSync(path)) {
|
|
826
|
-
return { settings: {},
|
|
704
|
+
return { settings: {}, agentTiers: emptyAgentTiersSource() };
|
|
827
705
|
}
|
|
828
706
|
try {
|
|
829
707
|
const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
830
|
-
return { settings: sanitize(raw),
|
|
708
|
+
return { settings: sanitize(raw), agentTiers: agentTiersSource(raw) };
|
|
831
709
|
} catch (err) {
|
|
832
710
|
const reason = err instanceof Error ? err.message : String(err);
|
|
833
711
|
console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
|
|
834
712
|
return {
|
|
835
713
|
settings: {},
|
|
836
|
-
workflow: {
|
|
837
|
-
...emptyWorkflowSource(),
|
|
838
|
-
present: true,
|
|
839
|
-
object: false,
|
|
840
|
-
blockedTiers: [...WORKFLOW_TIER_NAMES],
|
|
841
|
-
blockedDefaultTier: true,
|
|
842
|
-
},
|
|
843
714
|
// An unreadable file may have held the tier catalogue; block rather than
|
|
844
715
|
// fall through to whatever global happens to define.
|
|
845
716
|
agentTiers: { ...emptyAgentTiersSource(), present: true, object: false, blockedDefaultTier: true },
|
|
@@ -848,7 +719,7 @@ function readSettingsFile(path: string): ReadSettings {
|
|
|
848
719
|
}
|
|
849
720
|
|
|
850
721
|
/**
|
|
851
|
-
* Load merged settings: global provides defaults, project overrides.
|
|
722
|
+
* Load merged settings: global provides defaults, project overrides. Agent-tier
|
|
852
723
|
* profiles are complete replacement units: a project entry never inherits one
|
|
853
724
|
* field from the global entry, and an invalid project entry blocks that global
|
|
854
725
|
* entry instead of silently reviving it.
|
|
@@ -856,65 +727,16 @@ function readSettingsFile(path: string): ReadSettings {
|
|
|
856
727
|
export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
|
|
857
728
|
const global = readSettingsFile(globalPath());
|
|
858
729
|
const project = readSettingsFile(projectPath(cwd));
|
|
859
|
-
const {
|
|
860
|
-
const {
|
|
861
|
-
const workflow = mergeWorkflowSources(global.workflow, project.workflow);
|
|
730
|
+
const { agentTiers: _globalAgentTiers, ...globalSettings } = global.settings;
|
|
731
|
+
const { agentTiers: _projectAgentTiers, ...projectSettings } = project.settings;
|
|
862
732
|
const agentTiers = mergeAgentTierSources(global.agentTiers, project.agentTiers);
|
|
863
733
|
return {
|
|
864
734
|
...globalSettings,
|
|
865
735
|
...projectSettings,
|
|
866
|
-
...(workflow ? { workflow } : {}),
|
|
867
736
|
...(agentTiers ? { agentTiers } : {}),
|
|
868
737
|
};
|
|
869
738
|
}
|
|
870
739
|
|
|
871
|
-
function mergeWorkflowSources(global: WorkflowSource, project: WorkflowSource): WorkflowSettings | undefined {
|
|
872
|
-
if (!global.present && !project.present) return undefined;
|
|
873
|
-
|
|
874
|
-
const merged: WorkflowSettings = {};
|
|
875
|
-
if (project.present && !project.object) {
|
|
876
|
-
merged.blockedTiers = [...WORKFLOW_TIER_NAMES];
|
|
877
|
-
merged.blockedDefaultTier = true;
|
|
878
|
-
return merged;
|
|
879
|
-
}
|
|
880
|
-
if (project.defaultTierPresent) {
|
|
881
|
-
if (project.defaultTier !== undefined) {
|
|
882
|
-
merged.defaultTier = project.defaultTier;
|
|
883
|
-
} else {
|
|
884
|
-
merged.blockedDefaultTier = true;
|
|
885
|
-
}
|
|
886
|
-
} else {
|
|
887
|
-
if (global.defaultTier !== undefined) merged.defaultTier = global.defaultTier;
|
|
888
|
-
if (global.blockedDefaultTier || project.blockedDefaultTier) merged.blockedDefaultTier = true;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
const blocked = new Set<WorkflowTier>([...global.blockedTiers, ...project.blockedTiers]);
|
|
892
|
-
const tiers: Partial<Record<WorkflowTier, WorkflowTierProfile>> = {};
|
|
893
|
-
for (const tier of WORKFLOW_TIER_NAMES) {
|
|
894
|
-
if (project.tiersPresent && !project.tiersObject) {
|
|
895
|
-
blocked.add(tier);
|
|
896
|
-
continue;
|
|
897
|
-
}
|
|
898
|
-
if (project.tiersObject && Object.hasOwn(project.tierEntries, tier)) {
|
|
899
|
-
const profile = project.tierEntries[tier];
|
|
900
|
-
if (profile) {
|
|
901
|
-
tiers[tier] = profile;
|
|
902
|
-
blocked.delete(tier);
|
|
903
|
-
} else {
|
|
904
|
-
blocked.add(tier);
|
|
905
|
-
}
|
|
906
|
-
continue;
|
|
907
|
-
}
|
|
908
|
-
if (Object.hasOwn(global.tierEntries, tier)) {
|
|
909
|
-
const profile = global.tierEntries[tier];
|
|
910
|
-
if (profile) tiers[tier] = profile;
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
if (Object.keys(tiers).length > 0) merged.tiers = tiers;
|
|
914
|
-
if (blocked.size > 0) merged.blockedTiers = WORKFLOW_TIER_NAMES.filter((tier) => blocked.has(tier));
|
|
915
|
-
return merged.defaultTier !== undefined || merged.tiers !== undefined || merged.blockedTiers !== undefined || merged.blockedDefaultTier !== undefined ? merged : undefined;
|
|
916
|
-
}
|
|
917
|
-
|
|
918
740
|
/**
|
|
919
741
|
* Write project-local settings. Global is never touched from code.
|
|
920
742
|
* Returns `true` on success, `false` if the write (or mkdir) failed so the
|
|
@@ -980,7 +802,6 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
980
802
|
if (typeof s.rememberAgents === "boolean") appliers.setRememberAgents(s.rememberAgents);
|
|
981
803
|
if (s.agentMentions) appliers.setAgentMentions(s.agentMentions);
|
|
982
804
|
if (typeof s.supervisorQuestions === "boolean") appliers.setSupervisorQuestions(s.supervisorQuestions);
|
|
983
|
-
if (s.workflow) appliers.setWorkflow?.(s.workflow);
|
|
984
805
|
// Applied unconditionally so a session that had tiers and no longer does gets
|
|
985
806
|
// the empty catalogue rather than keeping the previous one.
|
|
986
807
|
appliers.setAgentTiers?.(s.agentTiers ?? {});
|