@signalridge/pi-subagents 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +102 -4
- package/examples/agent-tool-description.md +2 -3
- package/package.json +1 -1
- package/src/agent-manager.ts +21 -0
- package/src/agent-runner.ts +57 -4
- package/src/agent-tiers.ts +297 -0
- package/src/custom-agents.ts +21 -0
- package/src/index.ts +40 -13
- package/src/invocation-config.ts +33 -8
- package/src/nested-tools.ts +8 -2
- package/src/schedule.ts +3 -0
- package/src/settings.ts +257 -5
- package/src/types.ts +21 -0
- package/src/ui/conversation-viewer.ts +9 -4
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-tiers.ts — user-named model tiers for ordinary subagent spawns.
|
|
3
|
+
*
|
|
4
|
+
* A tier is one name for a (model, thinking) pair. The host agent picks a tier
|
|
5
|
+
* key and nothing else: the LLM-facing `Agent` tool exposes `tier` and does not
|
|
6
|
+
* expose `model` or `thinking`, so the choice of which model runs stays with
|
|
7
|
+
* whoever writes `subagents.json` rather than with the model deciding per call.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately separate from `workflow-tiers.ts`. Workflow tiers are the fixed
|
|
10
|
+
* `small | medium | large` vocabulary of the `pi-workflows` protocol and are
|
|
11
|
+
* typed as that union; agent tiers are arbitrary user-chosen names. Sharing one
|
|
12
|
+
* field between them would force `WorkflowTier` open to `string` and take the
|
|
13
|
+
* protocol's exhaustiveness with it, so the two keep separate settings, separate
|
|
14
|
+
* snapshot types, and separate fields on `AgentInvocation`. The mechanics they
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
|
|
27
|
+
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
28
|
+
import type { AgentTierProfile, AgentTiersSettings, TierThinking } from "./settings.js";
|
|
29
|
+
import type { AgentConfig, ThinkingLevel } from "./types.js";
|
|
30
|
+
|
|
31
|
+
/** `provider/id`, or undefined when no model was selected. */
|
|
32
|
+
function effectiveModelId(model: Model<Api> | undefined): string | undefined {
|
|
33
|
+
return model ? `${model.provider}/${model.id}` : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
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
|
+
let agentTiersSettings: AgentTiersSettings = {};
|
|
40
|
+
|
|
41
|
+
export function getAgentTiersSettings(): AgentTiersSettings {
|
|
42
|
+
return structuredClone(agentTiersSettings);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setAgentTiersSettings(settings: AgentTiersSettings): void {
|
|
46
|
+
agentTiersSettings = structuredClone(settings);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A key is usable when it is a bounded, non-blank, whitespace-free string.
|
|
51
|
+
*
|
|
52
|
+
* Whitespace is excluded because the key appears in tool descriptions and error
|
|
53
|
+
* messages as a bare token; a key with a space in it reads as two keys.
|
|
54
|
+
*/
|
|
55
|
+
export function isValidAgentTierKey(value: unknown): value is string {
|
|
56
|
+
return (
|
|
57
|
+
typeof value === "string" &&
|
|
58
|
+
value.length > 0 &&
|
|
59
|
+
value.length <= MAX_AGENT_TIER_KEY_LENGTH &&
|
|
60
|
+
value.trim() === value &&
|
|
61
|
+
!/\s/u.test(value)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Where the tier that was used came from; recorded for audit. */
|
|
66
|
+
export type AgentTierSource = "call" | "frontmatter" | "default";
|
|
67
|
+
|
|
68
|
+
/** Durable, JSON-safe record of how one spawn's model and thinking were chosen. */
|
|
69
|
+
export interface AgentTierResolutionSnapshot {
|
|
70
|
+
/** The tier key that was applied. */
|
|
71
|
+
tier: string;
|
|
72
|
+
source: AgentTierSource;
|
|
73
|
+
/** Effective provider/model id after resolution. */
|
|
74
|
+
model?: string;
|
|
75
|
+
/** Effective thinking level; omitted when the model supports none. */
|
|
76
|
+
thinking?: ThinkingLevel;
|
|
77
|
+
/** The profile's model reference, before resolution. */
|
|
78
|
+
configuredModel: string;
|
|
79
|
+
/** The profile's thinking value, before clamping. */
|
|
80
|
+
configuredThinking: TierThinking;
|
|
81
|
+
/** Level asked for after `inherit` resolved against the parent. */
|
|
82
|
+
requestedThinking?: ThinkingLevel;
|
|
83
|
+
/** True when pi-ai lowered the requested level for this model. */
|
|
84
|
+
clamped?: boolean;
|
|
85
|
+
diagnostic?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface AgentTierResolution {
|
|
89
|
+
model?: Model<Api>;
|
|
90
|
+
thinkingLevel?: ThinkingLevel;
|
|
91
|
+
snapshot?: AgentTierResolutionSnapshot;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ResolveAgentTierInput {
|
|
95
|
+
/** Tier key from the spawn call. Highest precedence. */
|
|
96
|
+
requestedTier?: string;
|
|
97
|
+
/** The agent's own config; supplies its default tier and legacy model/thinking. */
|
|
98
|
+
agentConfig?: AgentConfig;
|
|
99
|
+
/** Overrides the module-level settings; tests and callers with their own load. */
|
|
100
|
+
settings?: AgentTiersSettings;
|
|
101
|
+
parentModel?: Model<Api>;
|
|
102
|
+
parentThinking?: ThinkingLevel;
|
|
103
|
+
modelRegistry: ModelRegistry<Model<Api>>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Thrown for every fail-closed tier condition so callers can report it verbatim. */
|
|
107
|
+
export class AgentTierError extends Error {
|
|
108
|
+
constructor(message: string) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = "AgentTierError";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function knownTierKeys(settings: AgentTiersSettings): string[] {
|
|
115
|
+
return Object.keys(settings.profiles ?? {}).sort((a, b) => a.localeCompare(b));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tierKeyList(settings: AgentTiersSettings): string {
|
|
119
|
+
const keys = knownTierKeys(settings);
|
|
120
|
+
return keys.length > 0 ? keys.join(", ") : "(none configured)";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Which tier applies, and where it came from.
|
|
125
|
+
*
|
|
126
|
+
* An explicitly requested tier that does not exist is an error rather than a
|
|
127
|
+
* fallback: the host asked for a specific policy, and quietly running a
|
|
128
|
+
* different model than the one it selected is worse than refusing.
|
|
129
|
+
*/
|
|
130
|
+
function selectTier(
|
|
131
|
+
input: ResolveAgentTierInput,
|
|
132
|
+
settings: AgentTiersSettings,
|
|
133
|
+
): { tier: string; source: AgentTierSource } | undefined {
|
|
134
|
+
const requested = input.requestedTier;
|
|
135
|
+
if (requested !== undefined) {
|
|
136
|
+
if (!isValidAgentTierKey(requested)) {
|
|
137
|
+
throw new AgentTierError(
|
|
138
|
+
`Invalid agent tier key. Keys are non-empty, contain no whitespace, and are at most ` +
|
|
139
|
+
`${MAX_AGENT_TIER_KEY_LENGTH} characters. Available tiers: ${tierKeyList(settings)}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { tier: requested, source: "call" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const frontmatter = input.agentConfig?.agentTier;
|
|
146
|
+
if (frontmatter !== undefined) return { tier: frontmatter, source: "frontmatter" };
|
|
147
|
+
|
|
148
|
+
if (settings.blockedDefaultTier) {
|
|
149
|
+
throw new AgentTierError("agentTiers.defaultTier is blocked by malformed configuration");
|
|
150
|
+
}
|
|
151
|
+
if (settings.defaultTier !== undefined) return { tier: settings.defaultTier, source: "default" };
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function describeSource(source: AgentTierSource, agentName: string | undefined): string {
|
|
156
|
+
switch (source) {
|
|
157
|
+
case "call":
|
|
158
|
+
return "requested by the caller";
|
|
159
|
+
case "frontmatter":
|
|
160
|
+
return `set by agent "${agentName ?? "unknown"}"`;
|
|
161
|
+
case "default":
|
|
162
|
+
return "the configured agentTiers.defaultTier";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve a spawn's model and thinking from its tier.
|
|
168
|
+
*
|
|
169
|
+
* Returns `undefined` fields and no snapshot when no tier applies at all, which
|
|
170
|
+
* is how a workspace that has configured none keeps its previous behavior: the
|
|
171
|
+
* caller then falls back to the agent's legacy `model:`/`thinking:` frontmatter
|
|
172
|
+
* and finally to the parent session.
|
|
173
|
+
*/
|
|
174
|
+
export function resolveAgentTier(input: ResolveAgentTierInput): AgentTierResolution {
|
|
175
|
+
const settings = input.settings ?? agentTiersSettings;
|
|
176
|
+
const selected = selectTier(input, settings);
|
|
177
|
+
if (!selected) return {};
|
|
178
|
+
|
|
179
|
+
const { tier, source } = selected;
|
|
180
|
+
const origin = describeSource(source, input.agentConfig?.name);
|
|
181
|
+
|
|
182
|
+
if (settings.blockedProfiles?.includes(tier)) {
|
|
183
|
+
throw new AgentTierError(
|
|
184
|
+
`Agent tier "${tier}" (${origin}) is blocked by a malformed profile in subagents.json. ` +
|
|
185
|
+
`Fix or remove it; a tier is never silently replaced by another model.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const profile: AgentTierProfile | undefined = settings.profiles?.[tier];
|
|
190
|
+
if (!profile) {
|
|
191
|
+
throw new AgentTierError(
|
|
192
|
+
`Unknown agent tier "${tier}" (${origin}). Available tiers: ${tierKeyList(settings)}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A tier owns its model outright, so an unresolvable reference fails the spawn
|
|
197
|
+
// instead of degrading into whatever the parent happens to be running — the
|
|
198
|
+
// caller asked for this policy by name.
|
|
199
|
+
let model = input.parentModel;
|
|
200
|
+
if (profile.model !== "inherit") {
|
|
201
|
+
const resolved = resolveModel(profile.model, input.modelRegistry);
|
|
202
|
+
if (typeof resolved === "string") {
|
|
203
|
+
throw new AgentTierError(`Agent tier "${tier}" (${origin}) has an unavailable model: ${resolved}`);
|
|
204
|
+
}
|
|
205
|
+
model = resolved;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const requestedThinking = profile.thinking === "inherit" ? input.parentThinking : profile.thinking;
|
|
209
|
+
let thinkingLevel = requestedThinking;
|
|
210
|
+
let clamped = false;
|
|
211
|
+
let diagnostic: string | undefined;
|
|
212
|
+
if (model && requestedThinking) {
|
|
213
|
+
const clampedLevel = clampThinkingLevel(model, requestedThinking);
|
|
214
|
+
if (clampedLevel !== requestedThinking) {
|
|
215
|
+
clamped = true;
|
|
216
|
+
diagnostic =
|
|
217
|
+
`Thinking level "${requestedThinking}" is not supported by ${effectiveModelId(model) ?? "the selected model"}; ` +
|
|
218
|
+
`using "${clampedLevel}" (supported: ${getSupportedThinkingLevels(model).join(", ")}).`;
|
|
219
|
+
}
|
|
220
|
+
// "off" is a ModelThinkingLevel sentinel, not a ThinkingLevel an AgentSession
|
|
221
|
+
// accepts. Omitting the option leaves the provider's own off behavior alone.
|
|
222
|
+
thinkingLevel = clampedLevel === "off" ? undefined : (clampedLevel as ThinkingLevel);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const snapshot: AgentTierResolutionSnapshot = {
|
|
226
|
+
tier,
|
|
227
|
+
source,
|
|
228
|
+
...(effectiveModelId(model) ? { model: effectiveModelId(model) } : {}),
|
|
229
|
+
...(thinkingLevel ? { thinking: thinkingLevel } : {}),
|
|
230
|
+
configuredModel: profile.model,
|
|
231
|
+
configuredThinking: profile.thinking,
|
|
232
|
+
...(requestedThinking !== undefined ? { requestedThinking } : {}),
|
|
233
|
+
...(clamped ? { clamped: true } : {}),
|
|
234
|
+
...(diagnostic ? { diagnostic } : {}),
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return { model, thinkingLevel, snapshot };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The tier catalogue, rendered for the `Agent` tool description.
|
|
242
|
+
*
|
|
243
|
+
* The host has to know the vocabulary before its first call, so this is injected
|
|
244
|
+
* into the tool description at registration rather than exposed through a lookup
|
|
245
|
+
* tool the model would have to remember to call. Only names, descriptions,
|
|
246
|
+
* models and thinking levels appear — nothing here reads credentials.
|
|
247
|
+
*/
|
|
248
|
+
export function buildAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
249
|
+
const keys = knownTierKeys(settings);
|
|
250
|
+
if (keys.length === 0) return "";
|
|
251
|
+
|
|
252
|
+
const entries = keys.map((key) => {
|
|
253
|
+
const profile = settings.profiles?.[key];
|
|
254
|
+
if (!profile) return `- ${key}`;
|
|
255
|
+
// A profile without its own description is still worth listing; the key is
|
|
256
|
+
// the description in that case, which is what a terse config intends.
|
|
257
|
+
const summary = profile.description ?? key;
|
|
258
|
+
return `- ${key}: ${summary}\n model: ${profile.model}\n thinking: ${profile.thinking}`;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const defaultLine =
|
|
262
|
+
settings.defaultTier !== undefined ? `\n\nDefault tier: ${settings.defaultTier}` : "";
|
|
263
|
+
return `Available agent tiers:\n\n${entries.join("\n\n")}${defaultLine}\n\nThe caller may pass only a tier key. Do not pass model or thinking directly.`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** One line per tier, for the compact tool description. */
|
|
267
|
+
export function buildCompactAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
268
|
+
const keys = knownTierKeys(settings);
|
|
269
|
+
if (keys.length === 0) return "";
|
|
270
|
+
|
|
271
|
+
const entries = keys.map((key) => {
|
|
272
|
+
const profile = settings.profiles?.[key];
|
|
273
|
+
if (!profile) return `- ${key}`;
|
|
274
|
+
return `- ${key}: ${profile.description ?? key} (${profile.model}, thinking ${profile.thinking})`;
|
|
275
|
+
});
|
|
276
|
+
const defaultSuffix = settings.defaultTier !== undefined ? ` Default: ${settings.defaultTier}.` : "";
|
|
277
|
+
return `Agent tiers (pass \`tier\`, never model/thinking):\n${entries.join("\n")}${defaultSuffix}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The configured default tier key, or "" when none is set. */
|
|
281
|
+
export function getDefaultAgentTierText(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
282
|
+
return settings.defaultTier ?? "";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Description for the `tier` parameter, naming the keys this workspace defines. */
|
|
286
|
+
export function buildAgentTierParameterDescription(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
287
|
+
const keys = knownTierKeys(settings);
|
|
288
|
+
const available = keys.length > 0 ? keys.join(", ") : "none configured";
|
|
289
|
+
const fallback =
|
|
290
|
+
settings.defaultTier !== undefined
|
|
291
|
+
? ` Omit to use the agent's own tier, or "${settings.defaultTier}".`
|
|
292
|
+
: " Omit to use the agent's own tier.";
|
|
293
|
+
return (
|
|
294
|
+
`Model tier for this spawn, chosen by name. Available: ${available}.${fallback}` +
|
|
295
|
+
" A tier overrides the agent's default. Unknown tiers are rejected rather than substituted."
|
|
296
|
+
);
|
|
297
|
+
}
|
package/src/custom-agents.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
6
6
|
import { basename, join, resolve } from "node:path";
|
|
7
7
|
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { isValidAgentTierKey } from "./agent-tiers.js";
|
|
8
9
|
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
|
9
10
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
10
11
|
import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
|
|
@@ -174,6 +175,7 @@ function loadFromDir(
|
|
|
174
175
|
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
|
|
175
176
|
excludeExtensions: csvListOptional(fm.exclude_extensions),
|
|
176
177
|
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
|
178
|
+
agentTier: parseTier(fm.tier, path, warn),
|
|
177
179
|
model: str(fm.model),
|
|
178
180
|
thinking: str(fm.thinking) as ThinkingLevel | undefined,
|
|
179
181
|
maxTurns: nonNegativeInt(fm.max_turns),
|
|
@@ -266,6 +268,25 @@ function label(val: unknown): string | undefined {
|
|
|
266
268
|
return raw === undefined ? undefined : sanitizeDisplayText(raw);
|
|
267
269
|
}
|
|
268
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Parse the agent's default model tier.
|
|
273
|
+
*
|
|
274
|
+
* Only the shape is checked here; whether the key names a configured profile is
|
|
275
|
+
* decided at spawn time, because the catalogue lives in `subagents.json` and may
|
|
276
|
+
* legitimately change after this file was read. A malformed key is dropped with
|
|
277
|
+
* a warning rather than failing the load: the rest of the agent is still usable,
|
|
278
|
+
* and a spawn without a tier falls back to the configured default.
|
|
279
|
+
*/
|
|
280
|
+
function parseTier(val: unknown, path: string, warn: WarningSink): string | undefined {
|
|
281
|
+
if (val === undefined || val === null) return undefined;
|
|
282
|
+
if (isValidAgentTierKey(val)) return val;
|
|
283
|
+
warn(
|
|
284
|
+
`Ignoring invalid tier in ${path}: expected a non-empty single-word key`,
|
|
285
|
+
`tier:${warningIdentity(path)}`,
|
|
286
|
+
);
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
269
290
|
/** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
|
|
270
291
|
function nonNegativeInt(val: unknown): number | undefined {
|
|
271
292
|
return typeof val === "number" && val >= 0 ? val : undefined;
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,14 @@ import {
|
|
|
27
27
|
type ManagedSpawnTombstone,
|
|
28
28
|
} from "./agent-manager.js";
|
|
29
29
|
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
30
|
+
import {
|
|
31
|
+
buildAgentTierListText,
|
|
32
|
+
buildAgentTierParameterDescription,
|
|
33
|
+
buildCompactAgentTierListText,
|
|
34
|
+
getAgentTiersSettings,
|
|
35
|
+
getDefaultAgentTierText,
|
|
36
|
+
setAgentTiersSettings,
|
|
37
|
+
} from "./agent-tiers.js";
|
|
30
38
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
31
39
|
import { inChildSessionContext } from "./child-context.js";
|
|
32
40
|
import {
|
|
@@ -1000,7 +1008,7 @@ function activateRootRuntime(
|
|
|
1000
1008
|
...(effectiveTier === undefined ? {} : { tier: effectiveTier }),
|
|
1001
1009
|
};
|
|
1002
1010
|
const customConfig = getAgentConfig(dispatch.type);
|
|
1003
|
-
const resolvedConfig = resolveAgentInvocationConfig(customConfig, {
|
|
1011
|
+
const resolvedConfig = resolveAgentInvocationConfig(customConfig, { workflowTier: effectiveTier });
|
|
1004
1012
|
// A managed request cannot carry a model override, but an agent's frontmatter
|
|
1005
1013
|
// model still needs the same scope validation as a normal Agent call when no
|
|
1006
1014
|
// workflow tier is active. Tiered runs validate their effective policy inside
|
|
@@ -1227,6 +1235,7 @@ function activateRootRuntime(
|
|
|
1227
1235
|
setMaxSubagentDepth,
|
|
1228
1236
|
setFallbackSubagent,
|
|
1229
1237
|
setWorkflow: setWorkflowSettings,
|
|
1238
|
+
setAgentTiers: setAgentTiersSettings,
|
|
1230
1239
|
});
|
|
1231
1240
|
pi.events.emit("subagents:settings_loaded", { settings: startupSettings });
|
|
1232
1241
|
|
|
@@ -1257,8 +1266,16 @@ function activateRootRuntime(
|
|
|
1257
1266
|
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
1258
1267
|
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
1259
1268
|
// small/local models. Per-option details live in the param descriptions.
|
|
1269
|
+
// The catalogue is injected into the description rather than left to a lookup
|
|
1270
|
+
// tool: the host has to pick a tier on its first call, and a tool it must
|
|
1271
|
+
// remember to call first is a tool it will skip.
|
|
1272
|
+
const tierListText = buildAgentTierListText();
|
|
1273
|
+
const compactTierListText = buildCompactAgentTierListText();
|
|
1274
|
+
const tierSection = tierListText ? `\n\n${tierListText}` : "";
|
|
1275
|
+
const compactTierSection = compactTierListText ? `\n\n${compactTierListText}` : "";
|
|
1276
|
+
|
|
1260
1277
|
const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
|
|
1261
|
-
${buildCompactTypeListText()}
|
|
1278
|
+
${buildCompactTypeListText()}${compactTierSection}
|
|
1262
1279
|
|
|
1263
1280
|
Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
|
|
1264
1281
|
|
|
@@ -1274,7 +1291,7 @@ Notes:
|
|
|
1274
1291
|
Available agent types and the tools they have access to:
|
|
1275
1292
|
${buildTypeListText()}
|
|
1276
1293
|
|
|
1277
|
-
Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it
|
|
1294
|
+
Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.${tierSection}
|
|
1278
1295
|
|
|
1279
1296
|
When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
|
|
1280
1297
|
|
|
@@ -1294,8 +1311,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
|
|
|
1294
1311
|
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
1295
1312
|
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
|
|
1296
1313
|
- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
|
|
1297
|
-
- Use
|
|
1298
|
-
- Use thinking to control extended thinking level.
|
|
1314
|
+
- Use tier to pick the model profile for this spawn, by name. A tier overrides the agent's own default tier. Model and thinking are not callable parameters — they are what a tier resolves to.
|
|
1299
1315
|
- Use inherit_context if the agent needs the parent conversation history.
|
|
1300
1316
|
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.${scheduleGuideline}
|
|
1301
1317
|
|
|
@@ -1320,6 +1336,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1320
1336
|
const vars: Record<string, () => string> = {
|
|
1321
1337
|
typeList: buildTypeListText,
|
|
1322
1338
|
compactTypeList: buildCompactTypeListText,
|
|
1339
|
+
// Both carry their own leading blank line, so a template that drops the
|
|
1340
|
+
// placeholder inline renders byte-identically to the built-in description
|
|
1341
|
+
// whether or not any tier is configured.
|
|
1342
|
+
tierList: () => tierSection,
|
|
1343
|
+
compactTierList: () => compactTierSection,
|
|
1344
|
+
defaultTier: getDefaultAgentTierText,
|
|
1323
1345
|
agentDir: getAgentDir,
|
|
1324
1346
|
scheduleGuideline: () => scheduleGuideline,
|
|
1325
1347
|
};
|
|
@@ -1380,15 +1402,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1380
1402
|
subagent_type: Type.String({
|
|
1381
1403
|
description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
|
|
1382
1404
|
}),
|
|
1383
|
-
|
|
1384
|
-
Type.String({
|
|
1385
|
-
description:
|
|
1386
|
-
'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
|
|
1387
|
-
}),
|
|
1388
|
-
),
|
|
1389
|
-
thinking: Type.Optional(
|
|
1405
|
+
tier: Type.Optional(
|
|
1390
1406
|
Type.String({
|
|
1391
|
-
description:
|
|
1407
|
+
description: buildAgentTierParameterDescription(),
|
|
1392
1408
|
}),
|
|
1393
1409
|
),
|
|
1394
1410
|
max_turns: Type.Optional(
|
|
@@ -1746,6 +1762,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1746
1762
|
isolated,
|
|
1747
1763
|
inheritContext,
|
|
1748
1764
|
thinkingLevel: thinking,
|
|
1765
|
+
agentTier: resolvedConfig.requestedAgentTier,
|
|
1749
1766
|
isBackground: true,
|
|
1750
1767
|
isolation,
|
|
1751
1768
|
invocation: agentInvocation,
|
|
@@ -1875,6 +1892,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1875
1892
|
isolated,
|
|
1876
1893
|
inheritContext,
|
|
1877
1894
|
thinkingLevel: thinking,
|
|
1895
|
+
agentTier: resolvedConfig.requestedAgentTier,
|
|
1878
1896
|
isolation,
|
|
1879
1897
|
invocation: agentInvocation,
|
|
1880
1898
|
signal,
|
|
@@ -2763,6 +2781,15 @@ Do not wrap the response in a markdown code fence. Return only the file contents
|
|
|
2763
2781
|
(getWorkflowSettings().blockedTiers?.length ?? 0) > 0
|
|
2764
2782
|
? { workflow: getWorkflowSettings() }
|
|
2765
2783
|
: {}),
|
|
2784
|
+
// Same shape as the workflow block above: written back only when the user
|
|
2785
|
+
// actually configured tiers, so the snapshot never materializes an empty
|
|
2786
|
+
// catalogue into the project settings file.
|
|
2787
|
+
...(Object.keys(getAgentTiersSettings().profiles ?? {}).length > 0 ||
|
|
2788
|
+
getAgentTiersSettings().defaultTier !== undefined ||
|
|
2789
|
+
getAgentTiersSettings().blockedDefaultTier === true ||
|
|
2790
|
+
(getAgentTiersSettings().blockedProfiles?.length ?? 0) > 0
|
|
2791
|
+
? { agentTiers: getAgentTiersSettings() }
|
|
2792
|
+
: {}),
|
|
2766
2793
|
// Deliberately NOT `?? "general-purpose"`: every settings change writes the
|
|
2767
2794
|
// whole snapshot, and materializing the implicit default would turn it into
|
|
2768
2795
|
// explicit configuration — which then fails loudly if general-purpose later
|
package/src/invocation-config.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AgentConfig,
|
|
4
|
+
IsolationMode,
|
|
5
|
+
JoinMode,
|
|
6
|
+
ThinkingLevel,
|
|
7
|
+
} from "./types.js";
|
|
3
8
|
|
|
4
9
|
interface AgentInvocationParams {
|
|
10
|
+
/**
|
|
11
|
+
* User-named model tier requested at the call site. This is the only model
|
|
12
|
+
* control the LLM-facing `Agent` tool exposes; `model` and `thinking` below
|
|
13
|
+
* are reachable only from programmatic callers and the legacy RPC.
|
|
14
|
+
*/
|
|
15
|
+
tier?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Workflow protocol tier (`small | medium | large`), set only by the managed
|
|
18
|
+
* spawn path. Kept apart from `tier` so the protocol's union stays closed.
|
|
19
|
+
*/
|
|
20
|
+
workflowTier?: WorkflowTier;
|
|
5
21
|
model?: string;
|
|
6
22
|
thinking?: string;
|
|
7
23
|
max_turns?: number;
|
|
@@ -9,7 +25,6 @@ interface AgentInvocationParams {
|
|
|
9
25
|
inherit_context?: boolean;
|
|
10
26
|
isolated?: boolean;
|
|
11
27
|
isolation?: IsolationMode;
|
|
12
|
-
tier?: WorkflowTier;
|
|
13
28
|
}
|
|
14
29
|
|
|
15
30
|
export function resolveAgentInvocationConfig(
|
|
@@ -18,7 +33,9 @@ export function resolveAgentInvocationConfig(
|
|
|
18
33
|
): {
|
|
19
34
|
modelInput?: string;
|
|
20
35
|
modelFromParams: boolean;
|
|
21
|
-
tier
|
|
36
|
+
/** Passed to `resolveAgentTier` as the requested tier; precedence lives there. */
|
|
37
|
+
requestedAgentTier?: string;
|
|
38
|
+
workflowTier?: WorkflowTier;
|
|
22
39
|
thinking?: ThinkingLevel;
|
|
23
40
|
maxTurns?: number;
|
|
24
41
|
inheritContext: boolean;
|
|
@@ -28,17 +45,25 @@ export function resolveAgentInvocationConfig(
|
|
|
28
45
|
} {
|
|
29
46
|
return {
|
|
30
47
|
modelInput: agentConfig?.model ?? params.model,
|
|
31
|
-
|
|
48
|
+
requestedAgentTier: params.tier,
|
|
49
|
+
workflowTier: params.workflowTier,
|
|
32
50
|
modelFromParams: agentConfig?.model == null && params.model != null,
|
|
33
|
-
thinking: (agentConfig?.thinking ?? params.thinking) as
|
|
51
|
+
thinking: (agentConfig?.thinking ?? params.thinking) as
|
|
52
|
+
| ThinkingLevel
|
|
53
|
+
| undefined,
|
|
34
54
|
maxTurns: agentConfig?.maxTurns ?? params.max_turns,
|
|
35
|
-
inheritContext:
|
|
36
|
-
|
|
55
|
+
inheritContext:
|
|
56
|
+
agentConfig?.inheritContext ?? params.inherit_context ?? false,
|
|
57
|
+
runInBackground:
|
|
58
|
+
agentConfig?.runInBackground ?? params.run_in_background ?? false,
|
|
37
59
|
isolated: agentConfig?.isolated ?? params.isolated ?? false,
|
|
38
60
|
isolation: agentConfig?.isolation ?? params.isolation,
|
|
39
61
|
};
|
|
40
62
|
}
|
|
41
63
|
|
|
42
|
-
export function resolveJoinMode(
|
|
64
|
+
export function resolveJoinMode(
|
|
65
|
+
defaultJoinMode: JoinMode,
|
|
66
|
+
runInBackground: boolean,
|
|
67
|
+
): JoinMode | undefined {
|
|
43
68
|
return runInBackground ? defaultJoinMode : undefined;
|
|
44
69
|
}
|
package/src/nested-tools.ts
CHANGED
|
@@ -8,6 +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
12
|
import {
|
|
12
13
|
buildAgentRegistry,
|
|
13
14
|
getAgentConfigIn,
|
|
@@ -50,6 +51,8 @@ const NESTED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"] as
|
|
|
50
51
|
|
|
51
52
|
interface NestedSpawnOptions {
|
|
52
53
|
description: string;
|
|
54
|
+
/** User-named model tier; resolved by the runner like every other spawn path. */
|
|
55
|
+
agentTier?: string;
|
|
53
56
|
model?: Model<any>;
|
|
54
57
|
maxTurns?: number;
|
|
55
58
|
isolated?: boolean;
|
|
@@ -168,8 +171,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
168
171
|
prompt: Type.String({ description: "Self-contained task for the nested agent." }),
|
|
169
172
|
description: Type.String({ description: "Short 3-5 word task description." }),
|
|
170
173
|
subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.` }),
|
|
171
|
-
|
|
172
|
-
thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
|
|
174
|
+
tier: Type.Optional(Type.String({ description: buildAgentTierParameterDescription() })),
|
|
173
175
|
max_turns: Type.Optional(Type.Number({ minimum: 1 })),
|
|
174
176
|
run_in_background: Type.Optional(Type.Boolean()),
|
|
175
177
|
resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
|
|
@@ -252,6 +254,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
252
254
|
const childDepth = context.depth + 1;
|
|
253
255
|
const options: NestedSpawnOptions = {
|
|
254
256
|
description: params.description,
|
|
257
|
+
agentTier: invocation.requestedAgentTier,
|
|
255
258
|
model,
|
|
256
259
|
maxTurns: invocation.maxTurns,
|
|
257
260
|
isolated: invocation.isolated,
|
|
@@ -259,6 +262,9 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
259
262
|
thinkingLevel: invocation.thinking,
|
|
260
263
|
isolation: invocation.isolation,
|
|
261
264
|
invocation: {
|
|
265
|
+
...(invocation.requestedAgentTier === undefined
|
|
266
|
+
? {}
|
|
267
|
+
: { agentTier: invocation.requestedAgentTier }),
|
|
262
268
|
thinking: invocation.thinking,
|
|
263
269
|
maxTurns: invocation.maxTurns,
|
|
264
270
|
isolated: invocation.isolated,
|
package/src/schedule.ts
CHANGED
|
@@ -256,6 +256,9 @@ export class SubagentScheduler {
|
|
|
256
256
|
maxTurns: job.max_turns,
|
|
257
257
|
isolated: job.isolated,
|
|
258
258
|
thinkingLevel: job.thinking,
|
|
259
|
+
// Resolved at fire time against the catalogue as it is then, not as it
|
|
260
|
+
// was when the job was created: a schedule outlives config edits.
|
|
261
|
+
agentTier: job.tier,
|
|
259
262
|
isolation: job.isolation,
|
|
260
263
|
});
|
|
261
264
|
} catch (err) {
|