pi-better-harness 0.11.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 +5 -2
- package/node_modules/pi-better-subagents/agent-commands.ts +10 -8
- package/node_modules/pi-better-subagents/agent-inspection.ts +18 -5
- package/node_modules/pi-better-subagents/agents-catalog-tool.ts +5 -3
- package/node_modules/pi-better-subagents/catalog-resolver.ts +17 -3
- package/node_modules/pi-better-subagents/catalog-runtime.ts +36 -11
- 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 +1 -1
- package/node_modules/pi-better-subagents/docs/agent-catalog.md +10 -8
- package/node_modules/pi-better-subagents/index.ts +11 -3
- 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
|
@@ -44,11 +44,14 @@ nontrivial role-owned task, while the foreground coordinates, integrates, and
|
|
|
44
44
|
verifies. See [usage notes](docs/usage.md#delegation-mode).
|
|
45
45
|
|
|
46
46
|
`agents_catalog` shows each role's and named agent's default model and effort,
|
|
47
|
-
such as `default openai/gpt-6-sol@high`.
|
|
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
|
|
48
51
|
`model` and `thinking` on a role or agent spawn; name one only for a stated
|
|
49
52
|
reason. When a launch's model or effort differs from the default, its launch
|
|
50
53
|
line says so, for example
|
|
51
|
-
`model openai/gpt-6-astra@high (role default openai/gpt-6-sol@high)`.
|
|
54
|
+
`model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`.
|
|
52
55
|
|
|
53
56
|
## When To Use
|
|
54
57
|
|
|
@@ -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";
|
|
@@ -227,7 +227,7 @@ export function presentCatalog(snapshot: CatalogSnapshot, enrich?: LaunchEnriche
|
|
|
227
227
|
"Definition validity is not launchability. Model availability is decided only by the injected resolver.",
|
|
228
228
|
...snapshot.diagnostics.map((diagnostic) => formatDiagnostic(diagnostic)),
|
|
229
229
|
...entries.flatMap((entry) => [
|
|
230
|
-
entry
|
|
230
|
+
renderCatalogListLine(entry),
|
|
231
231
|
` description: ${JSON.stringify(entry.identity.description ?? "")}`,
|
|
232
232
|
]),
|
|
233
233
|
];
|
|
@@ -361,10 +361,10 @@ export function presentCatalogEntry(snapshot: CatalogSnapshot, id: string, enric
|
|
|
361
361
|
return view;
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
function renderHeader(view: OperationView, shownId: string): string {
|
|
365
|
+
return [
|
|
366
366
|
view.identity.kind ?? "missing",
|
|
367
|
-
|
|
367
|
+
shownId,
|
|
368
368
|
view.identity.name ? JSON.stringify(view.identity.name) : "unnamed",
|
|
369
369
|
`scope=${view.identity.scope ?? "none"}`,
|
|
370
370
|
`definitionValid=${yesNo(view.definitionValid)}`,
|
|
@@ -372,6 +372,19 @@ export function renderOperationView(view: OperationView): string {
|
|
|
372
372
|
`launchable=${view.launchable === null ? "unknown" : yesNo(view.launchable)}`,
|
|
373
373
|
...(view.defaults ? [`default ${view.defaults.label}`] : []),
|
|
374
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);
|
|
375
388
|
const lines = [
|
|
376
389
|
header,
|
|
377
390
|
`identity: id=${view.id} kind=${view.identity.kind ?? "unknown"} name=${JSON.stringify(view.identity.name ?? "")} description=${JSON.stringify(view.identity.description ?? "")}`,
|
|
@@ -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 = {
|
|
@@ -41,6 +42,7 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
|
|
|
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.",
|
|
@@ -50,7 +52,7 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
|
|
|
50
52
|
],
|
|
51
53
|
parameters: Type.Object({
|
|
52
54
|
action: Type.String({ description: "list or inspect. No other action is accepted." }),
|
|
53
|
-
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." })),
|
|
54
56
|
}),
|
|
55
57
|
async execute(
|
|
56
58
|
_toolCallId: string,
|
|
@@ -72,8 +74,8 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
|
|
|
72
74
|
details: { action, wrote: false, revision: listed.revision, entries: listed.entries, diagnostics: listed.diagnostics },
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
|
-
if (!params.id?.trim()) return toolError("inspect needs
|
|
76
|
-
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);
|
|
77
79
|
return {
|
|
78
80
|
content: [{ type: "text" as const, text: view.text }],
|
|
79
81
|
details: { action, wrote: false, view },
|
|
@@ -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
|
}
|
|
@@ -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;
|
|
@@ -256,14 +256,14 @@ export async function clarifyCatalogRequest(
|
|
|
256
256
|
* Launch-line note for a catalog run whose effective model or effort differs
|
|
257
257
|
* from the role or agent default. The wording names the cause, so a fallback
|
|
258
258
|
* or a capped effort is not mistaken for a caller override:
|
|
259
|
-
* - override: `model openai/gpt-6-astra@high (role default openai/gpt-6-sol@high)`
|
|
260
|
-
* - fallback: `model xai/grok-4.7@high (role default openai/gpt-6-sol@high unavailable; foreground fallback)`
|
|
261
|
-
* - capped effort: `model openai/gpt-6-sol@medium (role default openai/gpt-6-sol@high; effort capped at medium by the model)`
|
|
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
262
|
* Undefined for a non-catalog run, a definition with no default, or a launch
|
|
263
263
|
* that matches the default. Only the fields the definition sets are compared.
|
|
264
264
|
*/
|
|
265
265
|
export function catalogDefaultNote(
|
|
266
|
-
record: Pick<CatalogRunRecord, "kind" | "effective" | "modelSelection" | "effortSelection"> | undefined,
|
|
266
|
+
record: Pick<CatalogRunRecord, "kind" | "effective" | "modelSelection" | "effortSelection"> & { id?: string } | undefined,
|
|
267
267
|
model: string | undefined,
|
|
268
268
|
thinking: string | undefined,
|
|
269
269
|
): string | undefined {
|
|
@@ -279,8 +279,10 @@ export function catalogDefaultNote(
|
|
|
279
279
|
const effortDiffers = defaultEffort !== null && (thinking ?? null) !== defaultEffort;
|
|
280
280
|
if (!modelDiffers && !effortDiffers) return undefined;
|
|
281
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;
|
|
282
284
|
const causes = [
|
|
283
|
-
modelFallback ? `${
|
|
285
|
+
modelFallback ? `${owner} default ${defaultLabel} unavailable; ${FALLBACK_LABELS[modelSource!] ?? modelSource} fallback` : `${owner} default ${defaultLabel}`,
|
|
284
286
|
...(effortCapped ? [`effort capped at ${thinking ?? "the model default"} by the model`] : []),
|
|
285
287
|
];
|
|
286
288
|
return `model ${actual} (${causes.join("; ")})`;
|
|
@@ -547,8 +549,7 @@ async function allocateDirectRoleLabel(input: { roleId: string; roleName: string
|
|
|
547
549
|
}
|
|
548
550
|
|
|
549
551
|
export function roleSlug(roleId: string): string {
|
|
550
|
-
|
|
551
|
-
return raw.trim().toLowerCase();
|
|
552
|
+
return shortRoleName(roleId).trim().toLowerCase();
|
|
552
553
|
}
|
|
553
554
|
|
|
554
555
|
function composePrompt(instructions: string, task: string): string {
|
|
@@ -558,7 +559,28 @@ function composePrompt(instructions: string, task: string): string {
|
|
|
558
559
|
}
|
|
559
560
|
|
|
560
561
|
function agentIdOf(job: { agent?: unknown }): string | undefined {
|
|
561
|
-
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;
|
|
562
584
|
}
|
|
563
585
|
|
|
564
586
|
function roleIdsOf(job: { role?: unknown; roleIds?: unknown }): string[] {
|
|
@@ -569,8 +591,11 @@ function roleIdsOf(job: { role?: unknown; roleIds?: unknown }): string[] {
|
|
|
569
591
|
const ids: string[] = [];
|
|
570
592
|
for (const value of values) {
|
|
571
593
|
if (typeof value !== "string") continue;
|
|
572
|
-
|
|
573
|
-
|
|
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);
|
|
574
599
|
}
|
|
575
600
|
return ids;
|
|
576
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,7 +52,7 @@ 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
|
-
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 default openai/gpt-6-sol@high)
|
|
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
56
|
|
|
57
57
|
## Lifecycle attachment
|
|
58
58
|
|
|
@@ -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,6 +147,7 @@ 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,
|
|
@@ -178,7 +179,7 @@ const projectConfigDirName = typeof (PiCodingAgent as { CONFIG_DIR_NAME?: unknow
|
|
|
178
179
|
const CATALOG_GUIDELINES = [
|
|
179
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.",
|
|
180
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.",
|
|
181
|
-
"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.",
|
|
182
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.",
|
|
183
184
|
];
|
|
184
185
|
|
|
@@ -1425,8 +1426,8 @@ function timingSchemaFields() {
|
|
|
1425
1426
|
|
|
1426
1427
|
/** String for one role, or an array when the caller assigns more than one. Arrays reach clarification instead of being rejected. */
|
|
1427
1428
|
function catalogRoleSchema(purpose: string) {
|
|
1428
|
-
const one = `One role
|
|
1429
|
-
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.`;
|
|
1430
1431
|
return Type.Optional(Type.Union([
|
|
1431
1432
|
Type.String({ description: one }),
|
|
1432
1433
|
Type.Array(Type.String({ minLength: 1 }), { minItems: 1, description: many }),
|
|
@@ -1818,6 +1819,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1818
1819
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1819
1820
|
const p = params as SpawnParams;
|
|
1820
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);
|
|
1821
1825
|
|
|
1822
1826
|
const cfg = loadConfig();
|
|
1823
1827
|
const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
@@ -1962,6 +1966,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1962
1966
|
const gate = getSharedCapacityGate(countRunning);
|
|
1963
1967
|
|
|
1964
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);
|
|
1965
1973
|
|
|
1966
1974
|
let catalogSnapshot: CatalogSnapshot | undefined;
|
|
1967
1975
|
let catalogHost: CatalogHost | undefined;
|
|
@@ -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
|
},
|