pi-better-harness 0.10.0 → 0.12.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/node_modules/pi-better-subagents/README.md +10 -0
- package/node_modules/pi-better-subagents/agent-commands.ts +10 -8
- package/node_modules/pi-better-subagents/agent-inspection.ts +46 -5
- package/node_modules/pi-better-subagents/agents-catalog-tool.ts +7 -4
- package/node_modules/pi-better-subagents/batch.mjs +2 -2
- package/node_modules/pi-better-subagents/catalog-resolver.ts +17 -3
- package/node_modules/pi-better-subagents/catalog-runtime.ts +83 -7
- package/node_modules/pi-better-subagents/catalog-schema.ts +34 -0
- package/node_modules/pi-better-subagents/delegation.ts +1 -1
- package/node_modules/pi-better-subagents/docs/agent-catalog-lifecycle.md +3 -1
- package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +2 -0
- package/node_modules/pi-better-subagents/docs/agent-catalog.md +10 -8
- package/node_modules/pi-better-subagents/index.ts +30 -16
- package/node_modules/pi-better-subagents/navigator.mjs +1 -1
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/package.json +2 -2
|
@@ -43,6 +43,16 @@ uses `agents_catalog` to discover current role descriptions and delegates every
|
|
|
43
43
|
nontrivial role-owned task, while the foreground coordinates, integrates, and
|
|
44
44
|
verifies. See [usage notes](docs/usage.md#delegation-mode).
|
|
45
45
|
|
|
46
|
+
`agents_catalog` shows each role's and named agent's default model and effort,
|
|
47
|
+
such as `role developer "Developer" … default openai/gpt-6-sol@high`. Roles are
|
|
48
|
+
listed and passed by short name (`role: "developer"`); the stored id
|
|
49
|
+
`role.developer` also works, and named agents keep their full id
|
|
50
|
+
(`agent: "agent.payments"`). To launch on that default, omit
|
|
51
|
+
`model` and `thinking` on a role or agent spawn; name one only for a stated
|
|
52
|
+
reason. When a launch's model or effort differs from the default, its launch
|
|
53
|
+
line says so, for example
|
|
54
|
+
`model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`.
|
|
55
|
+
|
|
46
56
|
## When To Use
|
|
47
57
|
|
|
48
58
|
Use this package for independent coding, review, research, or verification work that can finish later. Do not use it for steps that need immediate foreground interaction or user clarification.
|
|
@@ -15,7 +15,9 @@ import {
|
|
|
15
15
|
CATALOG_SCHEMA_VERSION,
|
|
16
16
|
DiagnosticCodes,
|
|
17
17
|
EFFORT_LEVELS,
|
|
18
|
+
canonicalRoleId,
|
|
18
19
|
parseDefinition,
|
|
20
|
+
shortRoleName,
|
|
19
21
|
type AgentDefinition,
|
|
20
22
|
type Diagnostic,
|
|
21
23
|
type InstructionMode,
|
|
@@ -52,9 +54,9 @@ const KNOWN_FLAGS = new Set(["role", "scope", "name", "id", "mode", "description
|
|
|
52
54
|
const USAGE = [
|
|
53
55
|
"/agents list",
|
|
54
56
|
"/agents show <id> (inspect is the same command)",
|
|
55
|
-
"/agents create [--role <role
|
|
57
|
+
"/agents create [--role <role>] [--name <name>] [--scope user|project] [--mode add|replace] [--instructions <text>] [--model <provider/model>] [--effort <level>] [--tier <label>]",
|
|
56
58
|
"/agents reload",
|
|
57
|
-
"/agents import-codex <file.toml> [--scope user|project] [--role <role
|
|
59
|
+
"/agents import-codex <file.toml> [--scope user|project] [--role <role>]",
|
|
58
60
|
"Create and import write to the personal catalog unless --scope project is set.",
|
|
59
61
|
"Import confirms one base role and replace mode. It does not scan .codex/agents.",
|
|
60
62
|
].join("\n");
|
|
@@ -263,14 +265,14 @@ async function runCreate(
|
|
|
263
265
|
deps: AgentCommandDeps,
|
|
264
266
|
): Promise<AgentCommandResult> {
|
|
265
267
|
if (parsed.positionals.length > 1) {
|
|
266
|
-
return result("error", "create", false, "create takes flags, not a bare role name. Use --role <role
|
|
268
|
+
return result("error", "create", false, "create takes flags, not a bare role name. Use --role <role>. Nothing was written.");
|
|
267
269
|
}
|
|
268
270
|
const scope = readScope(parsed.flags);
|
|
269
271
|
if (scope.error) return result("error", "create", false, `${scope.error} Nothing was written.`);
|
|
270
272
|
if (scope.value === "project" && !loc.projectTrusted) return untrusted("create");
|
|
271
273
|
const snapshot = readCatalog(loc);
|
|
272
274
|
const roles = catalogRoles(snapshot);
|
|
273
|
-
let roleIds =
|
|
275
|
+
let roleIds = (parsed.flags.get("role") ?? []).map(canonicalRoleId);
|
|
274
276
|
if (roleIds.length > 1) {
|
|
275
277
|
const decision = await resolveRoleAssignment(roleIds.map((roleId) => ({ roleId })), {
|
|
276
278
|
hasUI: host.hasUI,
|
|
@@ -284,7 +286,7 @@ async function runCreate(
|
|
|
284
286
|
let roleId = roleIds[0];
|
|
285
287
|
if (!roleId) {
|
|
286
288
|
if (!host.hasUI) {
|
|
287
|
-
return clarification("create", `create needs --role <role
|
|
289
|
+
return clarification("create", `create needs --role <role>. UI is unavailable, so no role was chosen. Roles: ${roles.map((role) => shortRoleName(role.id)).join(", ") || "none"}. Nothing was written.`);
|
|
288
290
|
}
|
|
289
291
|
if (roles.length === 0) return result("error", "create", false, "No roles are available to use as a base. Nothing was written.");
|
|
290
292
|
const selected = await host.ui.select("Choose one base role", roles.map(roleOption));
|
|
@@ -292,7 +294,7 @@ async function runCreate(
|
|
|
292
294
|
roleId = selected.split(" ")[0];
|
|
293
295
|
}
|
|
294
296
|
if (!roleId || !roles.some((role) => role.id === roleId)) {
|
|
295
|
-
return result("error", "create", false, `Unknown role ${roleId ?? ""}.
|
|
297
|
+
return result("error", "create", false, `Unknown role ${shortRoleName(roleId ?? "")}. Valid roles: ${roles.map((role) => shortRoleName(role.id)).join(", ") || "none"}. Nothing was written.`);
|
|
296
298
|
}
|
|
297
299
|
let name = parsed.flags.get("name")?.[0];
|
|
298
300
|
if (!name?.trim()) {
|
|
@@ -407,7 +409,7 @@ async function runImport(
|
|
|
407
409
|
const roles = catalogRoles(snapshot);
|
|
408
410
|
if (roles.length === 0) return result("error", "import-codex", false, "No roles are available to use as the one base role. Nothing was imported.");
|
|
409
411
|
const suggestion = suggestBaseRoles(`${document.name}\n${document.description}\n${document.developerInstructions}`, roles);
|
|
410
|
-
const requestedRoles =
|
|
412
|
+
const requestedRoles = (parsed.flags.get("role") ?? []).map(canonicalRoleId);
|
|
411
413
|
if (requestedRoles.length > 1) {
|
|
412
414
|
const decision = await resolveRoleAssignment(requestedRoles.map((roleId) => ({ roleId })), {
|
|
413
415
|
hasUI: host.hasUI,
|
|
@@ -420,7 +422,7 @@ async function runImport(
|
|
|
420
422
|
}
|
|
421
423
|
let roleId = requestedRoles[0];
|
|
422
424
|
if (roleId && !roles.some((role) => role.id === roleId)) {
|
|
423
|
-
return result("error", "import-codex", false, `Unknown role ${roleId}. Nothing was imported.`);
|
|
425
|
+
return result("error", "import-codex", false, `Unknown role ${shortRoleName(roleId)}. Valid roles: ${roles.map((role) => shortRoleName(role.id)).join(", ")}. Nothing was imported.`);
|
|
424
426
|
}
|
|
425
427
|
const sourceRef = codexSourceRef(resolved);
|
|
426
428
|
const existingLookup = matchExisting(listScopeAgents(loc, scope.value).agents, document.proposedId, sourceRef);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and a catalog block stays not launchable even if enrichment disagrees.
|
|
9
9
|
*/
|
|
10
10
|
import { inspectCatalog, listCatalog, type CatalogInspection } from "./catalog-resolver.ts";
|
|
11
|
-
import { formatDiagnostic, type Diagnostic } from "./catalog-schema.ts";
|
|
11
|
+
import { formatDiagnostic, shortRoleName, type Diagnostic } from "./catalog-schema.ts";
|
|
12
12
|
import type { CatalogSnapshot } from "./catalog-store.ts";
|
|
13
13
|
import { loadConfig, normalizeTools, SAFE_DEFAULT_TOOLS, type SubagentConfig } from "./config.ts";
|
|
14
14
|
import { resolveExtensions } from "./extensions.ts";
|
|
@@ -80,6 +80,23 @@ export interface OperationField {
|
|
|
80
80
|
inherited: boolean;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
export interface DefinitionDefaults {
|
|
84
|
+
model: string | null;
|
|
85
|
+
effort: string | null;
|
|
86
|
+
/** Compact `model@effort` form, as shown in the catalog list and launch notes. */
|
|
87
|
+
label: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `model@effort`, `model`, or `effort <level>` when only an effort is set. Undefined when neither is set. */
|
|
91
|
+
export function formatModelEffort(model: string | null | undefined, effort: string | null | undefined): string | undefined {
|
|
92
|
+
const m = typeof model === "string" && model.trim() !== "" ? model : undefined;
|
|
93
|
+
const e = typeof effort === "string" && effort.trim() !== "" ? effort : undefined;
|
|
94
|
+
if (m && e) return `${m}@${e}`;
|
|
95
|
+
if (m) return m;
|
|
96
|
+
if (e) return `effort ${e}`;
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
export interface OperationCapabilities {
|
|
84
101
|
grantedByCatalog: false;
|
|
85
102
|
sameAsLegacySpawn: true;
|
|
@@ -113,6 +130,8 @@ export interface OperationView {
|
|
|
113
130
|
instructionMode?: string;
|
|
114
131
|
instructions?: string;
|
|
115
132
|
fields?: { model: OperationField; effort: OperationField; tier: OperationField };
|
|
133
|
+
/** The definition's own default model and effort (role default, or the agent's override or inherited value). Null when neither is set. */
|
|
134
|
+
defaults: DefinitionDefaults | null;
|
|
116
135
|
requestedModel: string | null;
|
|
117
136
|
requestedEffort: string | null;
|
|
118
137
|
actualModel: string | null;
|
|
@@ -208,7 +227,7 @@ export function presentCatalog(snapshot: CatalogSnapshot, enrich?: LaunchEnriche
|
|
|
208
227
|
"Definition validity is not launchability. Model availability is decided only by the injected resolver.",
|
|
209
228
|
...snapshot.diagnostics.map((diagnostic) => formatDiagnostic(diagnostic)),
|
|
210
229
|
...entries.flatMap((entry) => [
|
|
211
|
-
entry
|
|
230
|
+
renderCatalogListLine(entry),
|
|
212
231
|
` description: ${JSON.stringify(entry.identity.description ?? "")}`,
|
|
213
232
|
]),
|
|
214
233
|
];
|
|
@@ -311,6 +330,7 @@ export function presentCatalogEntry(snapshot: CatalogSnapshot, id: string, enric
|
|
|
311
330
|
instructionMode: inspection.instructionMode,
|
|
312
331
|
instructions: inspection.instructions,
|
|
313
332
|
fields,
|
|
333
|
+
defaults: definitionDefaults(fields),
|
|
314
334
|
requestedModel: enrichment?.requestedModel ?? fields?.model.value ?? null,
|
|
315
335
|
requestedEffort: enrichment?.requestedEffort ?? (fields?.effort.value ?? null),
|
|
316
336
|
actualModel: enrichment?.actualModel ?? null,
|
|
@@ -341,16 +361,30 @@ export function presentCatalogEntry(snapshot: CatalogSnapshot, id: string, enric
|
|
|
341
361
|
return view;
|
|
342
362
|
}
|
|
343
363
|
|
|
344
|
-
|
|
345
|
-
|
|
364
|
+
function renderHeader(view: OperationView, shownId: string): string {
|
|
365
|
+
return [
|
|
346
366
|
view.identity.kind ?? "missing",
|
|
347
|
-
|
|
367
|
+
shownId,
|
|
348
368
|
view.identity.name ? JSON.stringify(view.identity.name) : "unnamed",
|
|
349
369
|
`scope=${view.identity.scope ?? "none"}`,
|
|
350
370
|
`definitionValid=${yesNo(view.definitionValid)}`,
|
|
351
371
|
`catalogLaunchable=${yesNo(view.catalogLaunchable)}`,
|
|
352
372
|
`launchable=${view.launchable === null ? "unknown" : yesNo(view.launchable)}`,
|
|
373
|
+
...(view.defaults ? [`default ${view.defaults.label}`] : []),
|
|
353
374
|
].join(" ");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* One `agents_catalog` list line. A role shows its short name (`role developer
|
|
379
|
+
* "Developer" …`), which is what the `role` field accepts; a named agent keeps
|
|
380
|
+
* its full id. Inspect output and structured details keep the full id.
|
|
381
|
+
*/
|
|
382
|
+
export function renderCatalogListLine(view: OperationView): string {
|
|
383
|
+
return renderHeader(view, view.identity.kind === "role" ? shortRoleName(view.id) : view.id);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function renderOperationView(view: OperationView): string {
|
|
387
|
+
const header = renderHeader(view, view.id);
|
|
354
388
|
const lines = [
|
|
355
389
|
header,
|
|
356
390
|
`identity: id=${view.id} kind=${view.identity.kind ?? "unknown"} name=${JSON.stringify(view.identity.name ?? "")} description=${JSON.stringify(view.identity.description ?? "")}`,
|
|
@@ -397,6 +431,13 @@ export function renderOperationView(view: OperationView): string {
|
|
|
397
431
|
return lines.join("\n");
|
|
398
432
|
}
|
|
399
433
|
|
|
434
|
+
function definitionDefaults(fields: OperationView["fields"]): DefinitionDefaults | null {
|
|
435
|
+
const model = fields?.model.value ?? null;
|
|
436
|
+
const effort = fields?.effort.value ?? null;
|
|
437
|
+
const label = formatModelEffort(model, effort);
|
|
438
|
+
return label ? { model, effort, label } : null;
|
|
439
|
+
}
|
|
440
|
+
|
|
400
441
|
function field(value: { value: string | null; source: OperationField["source"]; explicit: boolean }): OperationField {
|
|
401
442
|
return {
|
|
402
443
|
value: value.value,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* collected. This module does not write files.
|
|
5
5
|
*/
|
|
6
6
|
import { presentCatalog, presentCatalogEntry, type LaunchEnricher } from "./agent-inspection.ts";
|
|
7
|
+
import { canonicalCatalogId } from "./catalog-schema.ts";
|
|
7
8
|
import { defaultUserRoot, loadCatalog } from "./catalog-store.ts";
|
|
8
9
|
|
|
9
10
|
type TypeModule = {
|
|
@@ -37,19 +38,21 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
|
|
|
37
38
|
return {
|
|
38
39
|
name: "agents_catalog" as const,
|
|
39
40
|
label: "Agents catalog",
|
|
40
|
-
description: "List or inspect role and named-agent definitions, including inheritance, diagnostics, restrictions, and whether launchability is actually known. This tool does not create, import, or edit definitions.",
|
|
41
|
+
description: "List or inspect role and named-agent definitions, including inheritance, default model and effort, diagnostics, restrictions, and whether launchability is actually known. This tool does not create, import, or edit definitions.",
|
|
41
42
|
promptSnippet: "List and inspect catalog roles and named agents, including inheritance and whether launchability is known.",
|
|
42
43
|
promptGuidelines: [
|
|
43
44
|
"Use agents_catalog to discover roles and named agents before subagent_spawn. It is read-only.",
|
|
45
|
+
"Roles are listed by short name. Pass that name as role (role: \"developer\"). Pass a named agent by its full id (agent: \"agent.payments\").",
|
|
44
46
|
"Do not treat a valid definition as launchable. definitionValid and catalogLaunchable do not select a model or start a child.",
|
|
45
47
|
"A role name or instruction that says read-only is not enforcement. Unsupported execution restrictions stay blocking.",
|
|
46
48
|
"Ask the user to run /agents create or /agents import-codex for writes. Those commands confirm role, scope, and import replacement.",
|
|
47
49
|
"Launch with the existing subagent_spawn or batch tool. Pass one agent or one role, not both. Two roles for one run need the user to choose one or split the work.",
|
|
48
50
|
"Put an authoritative model or effort on the structured invocation. Do not rely on copying a model name into the child prompt. Explicit invocation and workflow choices override definition defaults.",
|
|
51
|
+
"Each entry shows its default model@effort when set. Omit model and thinking on the spawn to use it; name one only for a stated reason.",
|
|
49
52
|
],
|
|
50
53
|
parameters: Type.Object({
|
|
51
54
|
action: Type.String({ description: "list or inspect. No other action is accepted." }),
|
|
52
|
-
id: Type.Optional(Type.String({ description: "
|
|
55
|
+
id: Type.Optional(Type.String({ description: "What to inspect: a role's short name (developer) or a named agent's id (agent.payments). The prefixed role id (role.developer) also works. Display names and filenames are not ids." })),
|
|
53
56
|
}),
|
|
54
57
|
async execute(
|
|
55
58
|
_toolCallId: string,
|
|
@@ -71,8 +74,8 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
|
|
|
71
74
|
details: { action, wrote: false, revision: listed.revision, entries: listed.entries, diagnostics: listed.diagnostics },
|
|
72
75
|
};
|
|
73
76
|
}
|
|
74
|
-
if (!params.id?.trim()) return toolError("inspect needs
|
|
75
|
-
const view = presentCatalogEntry(loadCatalog(host), params.id
|
|
77
|
+
if (!params.id?.trim()) return toolError("inspect needs a role name or agent id. Call agents_catalog with action list to see them. Nothing was written.");
|
|
78
|
+
const view = presentCatalogEntry(loadCatalog(host), canonicalCatalogId(params.id), deps.enrich);
|
|
76
79
|
return {
|
|
77
80
|
content: [{ type: "text" as const, text: view.text }],
|
|
78
81
|
details: { action, wrote: false, view },
|
|
@@ -215,8 +215,8 @@ export function formatBatchLaunchResponse({ batchId, batchName, launched, skippe
|
|
|
215
215
|
lines.push(
|
|
216
216
|
`Batch ${label} launched ${launched.length} subagent(s):`,
|
|
217
217
|
);
|
|
218
|
-
for (const { name, id } of launched) {
|
|
219
|
-
lines.push(`• ${name} → ${id}`);
|
|
218
|
+
for (const { name, id, modelNote } of launched) {
|
|
219
|
+
lines.push(`• ${name} → ${id}${modelNote ? ` · ${modelNote}` : ""}`);
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
DiagnosticCodes,
|
|
10
10
|
hasBlockingDiagnostic,
|
|
11
|
+
shortRoleName,
|
|
11
12
|
type CatalogDefinition,
|
|
12
13
|
type CatalogKind,
|
|
13
14
|
type Diagnostic,
|
|
@@ -161,9 +162,12 @@ export function listCatalog(snapshot: CatalogSnapshot): CatalogListEntry[] {
|
|
|
161
162
|
export function inspectCatalog(snapshot: CatalogSnapshot, id: string): CatalogInspection {
|
|
162
163
|
const entry = findEntry(snapshot, id);
|
|
163
164
|
if (!entry) {
|
|
165
|
+
const isAgent = id.trim().toLowerCase().startsWith("agent.");
|
|
164
166
|
const diagnostic = blockedDiagnostic(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
isAgent ? DiagnosticCodes.unknownAgent : DiagnosticCodes.unknownRole,
|
|
168
|
+
isAgent
|
|
169
|
+
? `No catalog definition has id ${id}. Check the id, or reload after adding the file. Display names and filenames are not ids.`
|
|
170
|
+
: `No role named ${shortRoleName(id)}. ${validRolesSentence(snapshot)} Check the name, or reload after adding the file. Display names and filenames are not ids.`,
|
|
167
171
|
id,
|
|
168
172
|
);
|
|
169
173
|
return {
|
|
@@ -212,7 +216,7 @@ function resolveRole(snapshot: CatalogSnapshot, roleId: string): Resolution {
|
|
|
212
216
|
return {
|
|
213
217
|
status: "not-found",
|
|
214
218
|
launchable: false,
|
|
215
|
-
diagnostics: [blockedDiagnostic(DiagnosticCodes.unknownRole, `Unknown role ${roleId}. It was not launched.`, roleId)],
|
|
219
|
+
diagnostics: [blockedDiagnostic(DiagnosticCodes.unknownRole, `Unknown role ${shortRoleName(roleId)}. ${validRolesSentence(snapshot)} It was not launched.`, roleId)],
|
|
216
220
|
};
|
|
217
221
|
}
|
|
218
222
|
if (entry.duplicate || !entry.definition || entry.definition.kind !== "role") {
|
|
@@ -393,6 +397,16 @@ function absentField<T>(): FieldValue<T> {
|
|
|
393
397
|
return { value: null, source: "absent", explicit: false };
|
|
394
398
|
}
|
|
395
399
|
|
|
400
|
+
/** Valid short role names, for an unknown-role message. */
|
|
401
|
+
function validRoleNames(snapshot: CatalogSnapshot): string[] {
|
|
402
|
+
return [...snapshot.roles.keys()].map(shortRoleName).sort();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function validRolesSentence(snapshot: CatalogSnapshot): string {
|
|
406
|
+
const names = validRoleNames(snapshot);
|
|
407
|
+
return names.length > 0 ? `Valid roles: ${names.join(", ")}.` : "No roles are defined.";
|
|
408
|
+
}
|
|
409
|
+
|
|
396
410
|
function findEntry(snapshot: CatalogSnapshot, id: string): CatalogEntry | undefined {
|
|
397
411
|
return snapshot.roles.get(id) ?? snapshot.agents.get(id) ?? snapshot.blocked.find((entry) => entry.id === id);
|
|
398
412
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `spawnSubagentRun`. This module does not grant tools, sandbox modes, or
|
|
5
5
|
* extensions, and it does not read task prose for model choices.
|
|
6
6
|
*/
|
|
7
|
-
import { describeDefaultLaunchCapabilities, LEGACY_CAPABILITY_CONTROLS, type LaunchEnricher, type LaunchEnrichment } from "./agent-inspection.ts";
|
|
7
|
+
import { describeDefaultLaunchCapabilities, formatModelEffort, LEGACY_CAPABILITY_CONTROLS, type LaunchEnricher, type LaunchEnrichment } from "./agent-inspection.ts";
|
|
8
8
|
import { resolveSelection, type EffectiveDefinition } from "./catalog-resolver.ts";
|
|
9
9
|
import { defaultUserRoot, loadCatalog, type CatalogSnapshot } from "./catalog-store.ts";
|
|
10
10
|
import { loadConfig, type SubagentConfig } from "./config.ts";
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { allocateCatalogLabel } from "./catalog-identity.ts";
|
|
23
23
|
import { resolveRoleAssignment, type RoleAssignment } from "./role-assignment.ts";
|
|
24
24
|
import { configureTierPolicy, DEFAULT_TIER_POLICY, type TierPolicy, type TierSpec } from "./tier-policy.ts";
|
|
25
|
-
import type
|
|
25
|
+
import { canonicalAgentId, canonicalRoleId, shortRoleName, type Diagnostic, type ThinkingLevel } from "./catalog-schema.ts";
|
|
26
26
|
|
|
27
27
|
export interface CatalogHost {
|
|
28
28
|
cwd: string;
|
|
@@ -252,6 +252,59 @@ export async function clarifyCatalogRequest(
|
|
|
252
252
|
return { status: "resolved", jobs: ordered.map(stripBookkeeping) };
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Launch-line note for a catalog run whose effective model or effort differs
|
|
257
|
+
* from the role or agent default. The wording names the cause, so a fallback
|
|
258
|
+
* or a capped effort is not mistaken for a caller override:
|
|
259
|
+
* - override: `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`
|
|
260
|
+
* - fallback: `model xai/grok-4.7@high (role developer default openai/gpt-6-sol@high unavailable; foreground fallback)`
|
|
261
|
+
* - capped effort: `model openai/gpt-6-sol@medium (role developer default openai/gpt-6-sol@high; effort capped at medium by the model)`
|
|
262
|
+
* Undefined for a non-catalog run, a definition with no default, or a launch
|
|
263
|
+
* that matches the default. Only the fields the definition sets are compared.
|
|
264
|
+
*/
|
|
265
|
+
export function catalogDefaultNote(
|
|
266
|
+
record: Pick<CatalogRunRecord, "kind" | "effective" | "modelSelection" | "effortSelection"> & { id?: string } | undefined,
|
|
267
|
+
model: string | undefined,
|
|
268
|
+
thinking: string | undefined,
|
|
269
|
+
): string | undefined {
|
|
270
|
+
if (!record) return undefined;
|
|
271
|
+
const defaultModel = record.effective.model.value;
|
|
272
|
+
const defaultEffort = record.effective.effort.value;
|
|
273
|
+
const defaultLabel = formatModelEffort(defaultModel, defaultEffort);
|
|
274
|
+
if (!defaultLabel) return undefined;
|
|
275
|
+
const modelSource = record.modelSelection?.source;
|
|
276
|
+
const modelFallback = defaultModel !== null && modelSource !== undefined && FALLBACK_SOURCES.has(modelSource);
|
|
277
|
+
const modelDiffers = defaultModel !== null && (modelFallback || !sameModel(defaultModel, model, modelSource));
|
|
278
|
+
const effortCapped = defaultEffort !== null && record.effortSelection?.adjusted === true && (thinking ?? null) !== defaultEffort;
|
|
279
|
+
const effortDiffers = defaultEffort !== null && (thinking ?? null) !== defaultEffort;
|
|
280
|
+
if (!modelDiffers && !effortDiffers) return undefined;
|
|
281
|
+
const actual = formatModelEffort(model ?? "Pi default", thinking) ?? "Pi default";
|
|
282
|
+
// A role run names its short role (`role developer default …`); an agent run keeps `agent default …`.
|
|
283
|
+
const owner = record.kind === "role" && record.id ? `role ${shortRoleName(record.id)}` : record.kind;
|
|
284
|
+
const causes = [
|
|
285
|
+
modelFallback ? `${owner} default ${defaultLabel} unavailable; ${FALLBACK_LABELS[modelSource!] ?? modelSource} fallback` : `${owner} default ${defaultLabel}`,
|
|
286
|
+
...(effortCapped ? [`effort capped at ${thinking ?? "the model default"} by the model`] : []),
|
|
287
|
+
];
|
|
288
|
+
return `model ${actual} (${causes.join("; ")})`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Model sources that mean the default could not be used, not that the caller chose another model. */
|
|
292
|
+
const FALLBACK_SOURCES: ReadonlySet<string> = new Set(["tier-candidate", "foreground", "configured-default"]);
|
|
293
|
+
const FALLBACK_LABELS: Record<string, string> = {
|
|
294
|
+
"tier-candidate": "same-tier",
|
|
295
|
+
foreground: "foreground",
|
|
296
|
+
"configured-default": "configured-default",
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
function sameModel(defaultModel: string, actual: string | undefined, source: string | undefined): boolean {
|
|
300
|
+
// The resolver launched the definition's own preference: same model, however it was spelled.
|
|
301
|
+
if (source === "role-default" || source === "agent-override") return true;
|
|
302
|
+
if (actual === undefined) return false;
|
|
303
|
+
if (defaultModel === actual) return true;
|
|
304
|
+
// A providerless default names the id; the resolved provider/id (the id may itself contain "/") still matches.
|
|
305
|
+
return !defaultModel.includes("/") ? actual.endsWith(`/${defaultModel}`) : false;
|
|
306
|
+
}
|
|
307
|
+
|
|
255
308
|
export async function prepareCatalogJob(snapshot: CatalogSnapshot, job: CatalogJobFields, host: CatalogHost): Promise<PreparedCatalogJob> {
|
|
256
309
|
const agent = agentIdOf(job);
|
|
257
310
|
const roles = roleIdsOf(job);
|
|
@@ -496,8 +549,7 @@ async function allocateDirectRoleLabel(input: { roleId: string; roleName: string
|
|
|
496
549
|
}
|
|
497
550
|
|
|
498
551
|
export function roleSlug(roleId: string): string {
|
|
499
|
-
|
|
500
|
-
return raw.trim().toLowerCase();
|
|
552
|
+
return shortRoleName(roleId).trim().toLowerCase();
|
|
501
553
|
}
|
|
502
554
|
|
|
503
555
|
function composePrompt(instructions: string, task: string): string {
|
|
@@ -507,7 +559,28 @@ function composePrompt(instructions: string, task: string): string {
|
|
|
507
559
|
}
|
|
508
560
|
|
|
509
561
|
function agentIdOf(job: { agent?: unknown }): string | undefined {
|
|
510
|
-
return typeof job.agent === "string" && job.agent.trim() !== "" ? job.agent
|
|
562
|
+
return typeof job.agent === "string" && job.agent.trim() !== "" ? canonicalAgentId(job.agent) : undefined;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Error text for a selector that is present but blank (`role: " "`,
|
|
567
|
+
* `agent: ""`, or an empty or blank role array). Absent or null means no
|
|
568
|
+
* selector and returns undefined. `where` names the field's owner, such as
|
|
569
|
+
* `shared` or `jobs[1]`.
|
|
570
|
+
*/
|
|
571
|
+
export function blankCatalogSelector(input: { agent?: unknown; role?: unknown } | undefined, where?: string): string | undefined {
|
|
572
|
+
if (!input) return undefined;
|
|
573
|
+
const field = (name: string) => (where ? `${where}.${name}` : name);
|
|
574
|
+
const blank = (value: unknown) => typeof value !== "string" || value.trim() === "";
|
|
575
|
+
if (input.agent !== undefined && input.agent !== null && blank(input.agent)) {
|
|
576
|
+
return `${field("agent")} is blank. Pass a named agent id such as agent.payments, or omit agent. No child was started.`;
|
|
577
|
+
}
|
|
578
|
+
const role = input.role;
|
|
579
|
+
if (role === undefined || role === null) return undefined;
|
|
580
|
+
if (Array.isArray(role) ? role.length === 0 || role.some(blank) : blank(role)) {
|
|
581
|
+
return `${field("role")} is blank. Pass a role name such as developer, or omit role. No child was started.`;
|
|
582
|
+
}
|
|
583
|
+
return undefined;
|
|
511
584
|
}
|
|
512
585
|
|
|
513
586
|
function roleIdsOf(job: { role?: unknown; roleIds?: unknown }): string[] {
|
|
@@ -518,8 +591,11 @@ function roleIdsOf(job: { role?: unknown; roleIds?: unknown }): string[] {
|
|
|
518
591
|
const ids: string[] = [];
|
|
519
592
|
for (const value of values) {
|
|
520
593
|
if (typeof value !== "string") continue;
|
|
521
|
-
|
|
522
|
-
|
|
594
|
+
// A bare name (`developer`) and the prefixed id (`role.developer`) are
|
|
595
|
+
// the same role. The canonical id always starts with `role.`, so a
|
|
596
|
+
// role field never reaches an `agent.*` named agent.
|
|
597
|
+
const id = canonicalRoleId(value);
|
|
598
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
523
599
|
}
|
|
524
600
|
return ids;
|
|
525
601
|
}
|
|
@@ -252,6 +252,40 @@ export function kindForId(id: string): CatalogKind | undefined {
|
|
|
252
252
|
return undefined;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/** Prefix every role id carries. Stored ids and files keep it; people type and read the short name. */
|
|
256
|
+
export const ROLE_ID_PREFIX = "role.";
|
|
257
|
+
|
|
258
|
+
/** Short, user-facing role name: `role.developer` becomes `developer`. Any other id is returned unchanged. */
|
|
259
|
+
export function shortRoleName(id: string): string {
|
|
260
|
+
return id.startsWith(ROLE_ID_PREFIX) ? id.slice(ROLE_ID_PREFIX.length) : id;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Canonical role id for a value typed into a `role` field. Trimmed and
|
|
265
|
+
* lowercased; a bare name gains the `role.` prefix, so `Developer` becomes
|
|
266
|
+
* `role.developer`. The result always starts with `role.`, so a role field
|
|
267
|
+
* can never select an `agent.*` named agent.
|
|
268
|
+
*/
|
|
269
|
+
export function canonicalRoleId(value: string): string {
|
|
270
|
+
const trimmed = value.trim().toLowerCase();
|
|
271
|
+
if (trimmed === "") return trimmed;
|
|
272
|
+
return trimmed.startsWith(ROLE_ID_PREFIX) ? trimmed : `${ROLE_ID_PREFIX}${trimmed}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Canonical named-agent id: trimmed and lowercased, since ids are lowercase-only. */
|
|
276
|
+
export function canonicalAgentId(value: string): string {
|
|
277
|
+
return value.trim().toLowerCase();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Canonical id for a field that takes either kind, such as `agents_catalog`
|
|
282
|
+
* inspect. An `agent.` id (any case) is lowercased; anything else is read as a role.
|
|
283
|
+
*/
|
|
284
|
+
export function canonicalCatalogId(value: string): string {
|
|
285
|
+
const lowered = value.trim().toLowerCase();
|
|
286
|
+
return lowered.startsWith("agent.") ? lowered : canonicalRoleId(lowered);
|
|
287
|
+
}
|
|
288
|
+
|
|
255
289
|
export function isPreferenceKey(value: string): value is PreferenceKey {
|
|
256
290
|
return (PREFERENCE_KEYS as readonly string[]).includes(value);
|
|
257
291
|
}
|
|
@@ -15,7 +15,7 @@ export function delegationPrompt(mode: DelegationMode): string {
|
|
|
15
15
|
case "manual":
|
|
16
16
|
return "Delegation mode: manual. Do not proactively delegate work. Use subagents only when the user explicitly asks for delegation or an active workflow explicitly requires it. A structured plan or plan mode does not override this restriction; perform planned work in the foreground unless explicitly required otherwise.";
|
|
17
17
|
case "coordinator":
|
|
18
|
-
return "Delegation mode: coordinator. The foreground coordinates, integrates, and verifies. Call agents_catalog to discover current available roles and inspect their descriptions. Delegate every nontrivial task covered by an available role according to its current description; do not assume bundled roles are unchanged. Keep orchestration, cross-role decisions, unowned or ambiguous work, integration, and final verification in the foreground. Inspect results and failures before concluding; do not poll for running children.";
|
|
18
|
+
return "Delegation mode: coordinator. The foreground coordinates, integrates, and verifies. Call agents_catalog to discover current available roles and inspect their descriptions. Pass a role to subagent_spawn by its short name, as listed (role: \"developer\"). Delegate every nontrivial task covered by an available role according to its current description; do not assume bundled roles are unchanged. Keep orchestration, cross-role decisions, unowned or ambiguous work, integration, and final verification in the foreground. Inspect results and failures before concluding; do not poll for running children.";
|
|
19
19
|
case "adaptive":
|
|
20
20
|
return "Delegation mode: adaptive. Delegate bounded, substantial independent work when a suitable subagent is available and useful; continue unblocked foreground work. Keep small, tightly coupled, or interactive work in the foreground. Inspect and integrate delegated results before final verification; do not poll.";
|
|
21
21
|
}
|
|
@@ -6,6 +6,8 @@ Catalog launches use the existing `spawnSubagentRun` path. There is no second sc
|
|
|
6
6
|
|
|
7
7
|
`subagent_spawn`, batch `shared`, and each batch job accept optional `agent`, `role`, and `alias`.
|
|
8
8
|
|
|
9
|
+
- `role` takes a role's short name (`role: "developer"`). It is trimmed and case-insensitive, and the stored id (`role.developer`) also works. A bare name only ever selects a role, never an `agent.*` named agent. An unknown name fails and lists the valid role names. `agent` ids are trimmed and lowercased the same way.
|
|
10
|
+
- A `role` or `agent` that is present but blank (`role: " "`) is rejected before any launch. Only an absent field means no selector.
|
|
9
11
|
- A per-job `agent` or `role` replaces the shared selector pair. It does not combine `shared.agent` with `job.role`.
|
|
10
12
|
- `model` and `thinking` still merge per field. Per-job wins.
|
|
11
13
|
- One agent or one role is a normal launch. Both, or more than one role id, asks the UI to choose one or split into separate runs.
|
|
@@ -28,7 +30,7 @@ The first `meta.json` write stores the launched `model` and `effort` plus a JSON
|
|
|
28
30
|
|
|
29
31
|
A named agent displays its defined name. A direct role asks `allocateCatalogLabel({ roleId, roleName, alias })` when that module is present. `roleName` is the slug (`developer`), not `role.developer`. The label is allocated in the local run registry. An explicit `alias` (or the legacy `name` when `alias` is omitted) is the alias; collisions are the allocator's numeric suffix. Batch-generated `job-N` labels are not aliases.
|
|
30
32
|
|
|
31
|
-
Navigator rows keep the display name, model, and effort. Details include the run id and, for catalog runs, the role
|
|
33
|
+
Navigator rows keep the display name, model, and effort. Details include the run id and, for catalog runs, the short role name (`developer`). The full `roleId` stays in `meta.json`.
|
|
32
34
|
|
|
33
35
|
## Tier candidates
|
|
34
36
|
|
|
@@ -52,6 +52,8 @@ Codex applies the agent file's model and `model_reasoning_effort` ahead of the c
|
|
|
52
52
|
|
|
53
53
|
`agents_catalog` accepts `list` and `inspect` only. It uses the same inspection view as `/agents show`. It has no write path. Launch remains `subagent_spawn` or the batch tool, with at most one `agent` or `role` selector. Those selectors are lifecycle's wiring, not this tool.
|
|
54
54
|
|
|
55
|
+
A list line names a role by its short name (`role developer "Developer" …`), the form the `role` field takes; the inspect header, `identity.id`, and the structured `id` keep the stored id `role.developer`. Named agents show their full `agent.<slug>` id in both. Each list line and the inspect header end with the definition's default model and effort when one is set, such as `default openai/gpt-6-sol@high`: a role's own default, or a named agent's override or inherited role default. The structured view carries the same value as `defaults: { model, effort, label }`, or `null` when neither is set. A spawn that omits `model` and `thinking` uses it. When a catalog launch's model or effort differs from it, the launch line (and each batch job line) adds a note such as `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`; a named agent's note reads `agent default …`. Only the fields the definition sets are compared. The note names the cause: a fallback from an unavailable default reads `(role developer default openai/gpt-6-sol@high unavailable; foreground fallback)` (or `same-tier` / `configured-default`), and an effort the model cannot run adds `effort capped at <level> by the model`.
|
|
56
|
+
|
|
55
57
|
## Lifecycle attachment
|
|
56
58
|
|
|
57
59
|
```ts
|
|
@@ -50,6 +50,8 @@ Look at payment edge cases before editing.
|
|
|
50
50
|
| `name` | Display text. Changing it does not change `id`. |
|
|
51
51
|
| Filename | Not an id. Discovery reads every `*.md` file in the scope directory, one level deep. |
|
|
52
52
|
|
|
53
|
+
People type and read a role by its short name: `developer` is `role.developer`. The `role` field of `subagent_spawn` and `subagent_spawn_batch` and the `agents_catalog` inspect id accept it (trimmed, case-insensitive), and `agents_catalog` lists roles by it. The prefixed id is what files, `roleId`, and run metadata store, and it keeps working as input. Named agents keep their full `agent.<slug>` id everywhere.
|
|
54
|
+
|
|
53
55
|
`kind` must match the id prefix. `roleIds`, `baseRoles`, `inherits`, and a list of roles are rejected. Agentier executable-role memberships are eligibility data, not extra inheritance parents, and are not accepted as additional `roleId`s.
|
|
54
56
|
|
|
55
57
|
## Portable preferences vs host controls
|
|
@@ -96,14 +98,14 @@ Model availability, same-tier fallback, and nearest-effort adjustment are not de
|
|
|
96
98
|
|
|
97
99
|
## Approved bundled roles
|
|
98
100
|
|
|
99
|
-
|
|
|
100
|
-
| --- | --- | --- | --- | --- |
|
|
101
|
-
| `role.researcher` | Researcher | `openai/gpt-6-sol` | medium | balanced |
|
|
102
|
-
| `role.explorer` | Explorer | `openai/gpt-6-luna` | medium | efficient |
|
|
103
|
-
| `role.product-manager` | Product Manager | `openai/gpt-6-sol` | medium | balanced |
|
|
104
|
-
| `role.developer` | Developer | `openai/gpt-6-sol` | high | balanced |
|
|
105
|
-
| `role.reviewer` | Reviewer | `openai/gpt-6-astra` | medium | frontier |
|
|
106
|
-
| `role.architect` | Architect | `openai/gpt-6-astra` | high | frontier |
|
|
101
|
+
| Role | Stored id | Name | Model | Effort | Tier |
|
|
102
|
+
| --- | --- | --- | --- | --- | --- |
|
|
103
|
+
| `researcher` | `role.researcher` | Researcher | `openai/gpt-6-sol` | medium | balanced |
|
|
104
|
+
| `explorer` | `role.explorer` | Explorer | `openai/gpt-6-luna` | medium | efficient |
|
|
105
|
+
| `product-manager` | `role.product-manager` | Product Manager | `openai/gpt-6-sol` | medium | balanced |
|
|
106
|
+
| `developer` | `role.developer` | Developer | `openai/gpt-6-sol` | high | balanced |
|
|
107
|
+
| `reviewer` | `role.reviewer` | Reviewer | `openai/gpt-6-astra` | medium | frontier |
|
|
108
|
+
| `architect` | `role.architect` | Architect | `openai/gpt-6-astra` | high | frontier |
|
|
107
109
|
|
|
108
110
|
Bundled descriptions are routing boundaries for coordinator mode. Researcher owns
|
|
109
111
|
external and documentary evidence; Explorer maps repository code paths; Product
|
|
@@ -147,10 +147,12 @@ import { createAgentOperations } from "./agent-operations.ts";
|
|
|
147
147
|
import {
|
|
148
148
|
clarifyCatalogRequest,
|
|
149
149
|
createLaunchEnricher,
|
|
150
|
+
blankCatalogSelector,
|
|
150
151
|
hasCatalogSelector,
|
|
151
152
|
loadLaunchSnapshot,
|
|
152
153
|
noteCatalogHost,
|
|
153
154
|
prepareCatalogJob,
|
|
155
|
+
catalogDefaultNote,
|
|
154
156
|
tiersForLaunch,
|
|
155
157
|
type CatalogHost,
|
|
156
158
|
type CatalogJobFields,
|
|
@@ -175,8 +177,9 @@ const projectConfigDirName = typeof (PiCodingAgent as { CONFIG_DIR_NAME?: unknow
|
|
|
175
177
|
: ".pi";
|
|
176
178
|
|
|
177
179
|
const CATALOG_GUIDELINES = [
|
|
180
|
+
"With an agent or role, omit model and thinking to launch on its default model and effort, which agents_catalog shows. Name a model or effort only for a stated reason, such as a task or workflow instruction, and never copy one from another role's runs.",
|
|
178
181
|
"When a task, workflow, or skill instruction names a model or effort, translate that authoritative choice into the structured model and thinking arguments before spawning. The runtime does not parse prose, quoted model names, or comparisons, and copying a model into the child prompt does not change the launch.",
|
|
179
|
-
"Optional agent, role, and alias select a catalog definition. Pass one agent id or one role
|
|
182
|
+
"Optional agent, role, and alias select a catalog definition. Pass one named agent id (agent: \"agent.payments\") or one role by its short name (role: \"developer\"); the role.developer form also works. Pass an array of role names when one run was given more than one role: that call asks to choose one or split, and without a UI choice it returns clarification-needed and starts no child. Naming both an agent and a role does the same. One job's choice does not change another job. A named agent displays its defined name. A direct role displays the label allocated from the local run registry. Calls without agent or role keep the existing name and model chain.",
|
|
180
183
|
"Catalog model and effort are resolved before the child starts. An unavailable explicit model or unsupported explicit effort does not launch and does not fall back. The catalog grants no tools, sandbox modes, extensions, or permissions.",
|
|
181
184
|
];
|
|
182
185
|
|
|
@@ -1423,8 +1426,8 @@ function timingSchemaFields() {
|
|
|
1423
1426
|
|
|
1424
1427
|
/** String for one role, or an array when the caller assigns more than one. Arrays reach clarification instead of being rejected. */
|
|
1425
1428
|
function catalogRoleSchema(purpose: string) {
|
|
1426
|
-
const one = `One role
|
|
1427
|
-
const many = `Two or more role
|
|
1429
|
+
const one = `One role by short name (developer) for this ${purpose}; role.developer also works, case-insensitive. Mutually exclusive with agent.`;
|
|
1430
|
+
const many = `Two or more role names for this ${purpose}. An array is ambiguous and asks to choose one role or split into separate runs. No child launches until that choice is made.`;
|
|
1428
1431
|
return Type.Optional(Type.Union([
|
|
1429
1432
|
Type.String({ description: one }),
|
|
1430
1433
|
Type.Array(Type.String({ minLength: 1 }), { minItems: 1, description: many }),
|
|
@@ -1551,6 +1554,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1551
1554
|
runtime: string;
|
|
1552
1555
|
warn: string;
|
|
1553
1556
|
sandboxDir?: string;
|
|
1557
|
+
/** Set only when a catalog run's model or effort differs from its role or agent default. */
|
|
1558
|
+
modelNote?: string;
|
|
1554
1559
|
}> {
|
|
1555
1560
|
assertThinkingLevel(p.thinking);
|
|
1556
1561
|
const permissionPlan = resolveSubagentPermissions(pi, p.sandbox);
|
|
@@ -1766,7 +1771,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1766
1771
|
`${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
|
|
1767
1772
|
`Add a toolExtensions entry in config.json.\n`
|
|
1768
1773
|
: "");
|
|
1769
|
-
|
|
1774
|
+
const modelNote = catalogDefaultNote(p.catalog, model, thinking);
|
|
1775
|
+
return { id, meta, spawned, runtime: runtime + (meta.timing ? `${formatTimingLimits(meta.timing, startedAt)}\n` : ""), warn, sandboxDir, modelNote };
|
|
1770
1776
|
}
|
|
1771
1777
|
|
|
1772
1778
|
// ---- subagent_spawn -------------------------------------------------
|
|
@@ -1783,7 +1789,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1783
1789
|
"After subagent_spawn, do NOT call subagent_output or subagent_result in a loop to wait for the result, and do NOT sleep. The run completes on its own and reports back on the next turn.",
|
|
1784
1790
|
"Call subagent_result after a completion or attention callback, or when the user explicitly asks for the result. Use subagent_output only when the user explicitly asks how a run is progressing; never use either tool to poll.",
|
|
1785
1791
|
...SUBAGENT_ORCHESTRATION_GUIDELINES,
|
|
1786
|
-
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child.
|
|
1792
|
+
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Without an agent or role, pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
|
|
1787
1793
|
...CATALOG_GUIDELINES,
|
|
1788
1794
|
"By default the subagent is sandboxed. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Without published settings, legacy write confinement applies. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
|
|
1789
1795
|
"Every run is timed by the harness: a soft deadline (default 30 min) steers the child to wrap up and wakes you once, the run is stopped after grace_minutes (reason deadline), a hard ceiling (default 90 min) stops it without grace (reason ceiling), and no progress for stuck_minutes (default 10) wakes you once (reason stuck). Set deadline_minutes/max_minutes/stuck_minutes to fit the task instead of writing a time limit into the prompt; do not stop a slow child that is still making progress.",
|
|
@@ -1795,8 +1801,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1795
1801
|
agent: Type.Optional(Type.String({ description: "Named agent id (agent.<slug>). Mutually exclusive with role. The navigator shows the defined agent name." })),
|
|
1796
1802
|
role: catalogRoleSchema("direct role launch"),
|
|
1797
1803
|
alias: Type.Optional(Type.String({ description: "Per-run display alias for a direct role launch, such as checkout. Not a reusable agent. Colliding aliases gain a numeric suffix." })),
|
|
1798
|
-
model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort (for example openai/gpt-5.5@high). Providerless patterns are resolved by Pi.
|
|
1799
|
-
thinking: Type.Optional(Type.String({ description: "Reasoning effort for the child: off, minimal, low, medium, high, xhigh, or max
|
|
1804
|
+
model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort (for example openai/gpt-5.5@high). Providerless patterns are resolved by Pi. Omit to use the agent or role default; without one, inherit the foreground model. Put authoritative model choices here; do not rely on prompt text." })),
|
|
1805
|
+
thinking: Type.Optional(Type.String({ description: "Reasoning effort for the child: off, minimal, low, medium, high, xhigh, or max. Omit to use the agent or role default; without one, the Pi/model default." })),
|
|
1800
1806
|
tools: Type.Optional(Type.String({ description: "Tool allowlist: comma-separated names the child may use (e.g. 'read,bash,web_fetch'). This ALSO selects which extensions load — only packages backing a requested tool are loaded. Defaults to the configured safe set." })),
|
|
1801
1807
|
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist, applied on top of the allowlist." })),
|
|
1802
1808
|
clean: Type.Optional(Type.Boolean({ description: "Run a hermetic child with NO extensions at all (only built-ins: read, bash, edit, write). Default false — the extensions backing the requested tools load, so web_fetch and model auth (e.g. xai) work." })),
|
|
@@ -1813,6 +1819,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1813
1819
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1814
1820
|
const p = params as SpawnParams;
|
|
1815
1821
|
if (p.prompt.trim() === "") throw new Error("prompt is empty.");
|
|
1822
|
+
// A present-but-blank role or agent is a mistake, not a request for a catalog-free child.
|
|
1823
|
+
const blankSelector = blankCatalogSelector(p);
|
|
1824
|
+
if (blankSelector) throw new Error(blankSelector);
|
|
1816
1825
|
|
|
1817
1826
|
const cfg = loadConfig();
|
|
1818
1827
|
const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
@@ -1841,21 +1850,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1841
1850
|
throw new Error(`Choosing split needs ${catalog.jobs.length} subagent slots, but only one was free. Nothing was launched.`);
|
|
1842
1851
|
}
|
|
1843
1852
|
reserved += extra;
|
|
1844
|
-
const launched: { name?: string; id: string }[] = [];
|
|
1853
|
+
const launched: { name?: string; id: string; modelNote?: string }[] = [];
|
|
1845
1854
|
for (const job of catalog.jobs) {
|
|
1846
|
-
const { id } = await spawnSubagentRun(ctx, job);
|
|
1855
|
+
const { id, modelNote } = await spawnSubagentRun(ctx, job);
|
|
1847
1856
|
gate.commit(1);
|
|
1848
1857
|
reserved -= 1;
|
|
1849
|
-
launched.push({ name: job.name, id });
|
|
1858
|
+
launched.push({ name: job.name, id, modelNote });
|
|
1850
1859
|
}
|
|
1851
|
-
return text(launched.map((item) => `Subagent launched: ${item.name ? `${item.name} ` : ""}id=${item.id}.`).join("\n"));
|
|
1860
|
+
return text(launched.map((item) => `Subagent launched: ${item.name ? `${item.name} ` : ""}id=${item.id}.${item.modelNote ? ` ${item.modelNote}.` : ""}`).join("\n"));
|
|
1852
1861
|
}
|
|
1853
1862
|
Object.assign(p, catalog.jobs[0]);
|
|
1854
|
-
const { id, spawned, runtime, warn, sandboxDir } = await spawnSubagentRun(ctx, p);
|
|
1863
|
+
const { id, spawned, runtime, warn, sandboxDir, modelNote } = await spawnSubagentRun(ctx, p);
|
|
1855
1864
|
gate.commit(1);
|
|
1856
1865
|
reserved = 0;
|
|
1857
1866
|
return text(
|
|
1858
1867
|
`Subagent launched: ${p.name ? `${p.name} ` : ""}id=${id} (pid ${spawned.pid}).\n` +
|
|
1868
|
+
(modelNote ? `${modelNote[0]!.toUpperCase()}${modelNote.slice(1)}.\n` : "") +
|
|
1859
1869
|
(p.callback === false
|
|
1860
1870
|
? `Running in the background; the foreground is free. It will finish quietly — read the result with subagent_result id=${id}.\n`
|
|
1861
1871
|
: `Running in the background; the foreground is free. Its result will be posted back here when it finishes.\n`) +
|
|
@@ -1898,7 +1908,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1898
1908
|
agent: Type.Optional(Type.String({ description: "Named agent id applied to jobs that do not select their own agent or role." })),
|
|
1899
1909
|
role: catalogRoleSchema("shared role selector"),
|
|
1900
1910
|
alias: Type.Optional(Type.String({ description: "Direct-role alias applied when a job does not set alias." })),
|
|
1901
|
-
model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort
|
|
1911
|
+
model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort. Omit to use the agent or role default; without one, inherit the foreground model. Put authoritative model choices here; prompt text is not parsed." })),
|
|
1902
1912
|
thinking: Type.Optional(Type.String({ description: "Reasoning effort applied to every job: off, minimal, low, medium, high, xhigh, or max." })),
|
|
1903
1913
|
tools: Type.Optional(Type.String({ description: "Tool allowlist applied to every job." })),
|
|
1904
1914
|
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist applied to every job." })),
|
|
@@ -1956,6 +1966,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1956
1966
|
const gate = getSharedCapacityGate(countRunning);
|
|
1957
1967
|
|
|
1958
1968
|
validateBatchPlan({ shared: p.shared, jobs: p.jobs, onCapacity: p.onCapacity, config: cfg });
|
|
1969
|
+
// A present-but-blank role or agent rejects the whole batch before any launch.
|
|
1970
|
+
const blankSelector = blankCatalogSelector(p.shared, "shared")
|
|
1971
|
+
?? p.jobs.map((job, index) => blankCatalogSelector(job, `jobs[${index}]`)).find(Boolean);
|
|
1972
|
+
if (blankSelector) throw new Error(blankSelector);
|
|
1959
1973
|
|
|
1960
1974
|
let catalogSnapshot: CatalogSnapshot | undefined;
|
|
1961
1975
|
let catalogHost: CatalogHost | undefined;
|
|
@@ -1997,7 +2011,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1997
2011
|
|
|
1998
2012
|
const names = assignBatchJobNames(p.jobs);
|
|
1999
2013
|
const batchId = nextBatchId();
|
|
2000
|
-
const launched: { name: string; id: string }[] = [];
|
|
2014
|
+
const launched: { name: string; id: string; modelNote?: string }[] = [];
|
|
2001
2015
|
const failed: { name: string; reason: string }[] = [];
|
|
2002
2016
|
const skipped: { name: string }[] = [];
|
|
2003
2017
|
// How many reject-mode reserved slots are still held (not yet committed/released).
|
|
@@ -2035,10 +2049,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2035
2049
|
name = prepared.assign.name;
|
|
2036
2050
|
}
|
|
2037
2051
|
}
|
|
2038
|
-
const { id } = await spawnSubagentRun(ctx, { ...merged, name }, { batchId, batchName: p.batchName });
|
|
2052
|
+
const { id, modelNote } = await spawnSubagentRun(ctx, { ...merged, name }, { batchId, batchName: p.batchName });
|
|
2039
2053
|
gate.commit(1);
|
|
2040
2054
|
if (!launchAvailable) reservedRemaining -= 1;
|
|
2041
|
-
launched.push({ name, id });
|
|
2055
|
+
launched.push({ name, id, ...(modelNote ? { modelNote } : {}) });
|
|
2042
2056
|
} catch (err) {
|
|
2043
2057
|
gate.release(1);
|
|
2044
2058
|
if (!launchAvailable) reservedRemaining -= 1;
|
|
@@ -399,7 +399,7 @@ export function buildNavigatorDetail(id, deps) {
|
|
|
399
399
|
return {
|
|
400
400
|
id: meta.id,
|
|
401
401
|
name: meta.name,
|
|
402
|
-
role: meta.catalog?.
|
|
402
|
+
role: meta.catalog?.roleName || (meta.catalog?.roleId ? String(meta.catalog.roleId).replace(/^role\./, "") : undefined) || undefined,
|
|
403
403
|
status,
|
|
404
404
|
model: deps.shortModel(meta.model),
|
|
405
405
|
effort: effortRaw ? String(effortRaw) : undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"pi-better-plan": "0.5.0",
|
|
56
56
|
"pi-better-sandbox": "0.7.1",
|
|
57
57
|
"pi-better-ssh": "0.1.1",
|
|
58
|
-
"pi-better-subagents": "0.
|
|
58
|
+
"pi-better-subagents": "0.9.0",
|
|
59
59
|
"smol-toml": "1.9.0",
|
|
60
60
|
"yaml": "^2.9.1"
|
|
61
61
|
},
|