@henryqw/pi-subagent 2.5.1 → 2.10.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/CONTEXT.md +3 -3
- package/README.md +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -5
- package/extensions/config.ts +131 -0
- package/extensions/subagent.ts +200 -22
- package/package.json +2 -2
package/CONTEXT.md
CHANGED
|
@@ -17,11 +17,11 @@ Provide validated user Roles, shared task-model Pi launch policy, generic manage
|
|
|
17
17
|
|
|
18
18
|
## Invariants
|
|
19
19
|
|
|
20
|
-
- One Delegated Task creates one ephemeral child process and no saved session.
|
|
21
|
-
- Up to
|
|
20
|
+
- One Delegated Task creates one ephemeral child process and no saved session. Default timeouts: 10-minute soft deadline; active model/tool execution or activity within the last minute grants one 5-minute grace period before a hard stop. Every value is configurable via the `timeout` object in `~/.pi/agent/config/pi-subagent.json` (`softMinutes`, `graceMinutes`, `activeWindowSeconds`).
|
|
21
|
+
- Up to five active ephemeral `delegate_task` children run per Main by default, configurable via `maxSubagents` in `~/.pi/agent/config/pi-subagent.json` or the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable; excess calls wait FIFO. Queued calls do not start a child or consume child timeout. Managed Herdr workers are unaffected.
|
|
22
22
|
- Ambient child extensions and Skills stay disabled; Role explicitly selects extension sources and named Skills. Pi loads Skills supplied by those extension packages or their resource discovery. Omitted Role tools use Pi's effective `defaultTools`; an explicit list sets base tools while loaded extension tools activate automatically.
|
|
23
23
|
- Role Skill names resolve through Main's effective Pi Skill registry; unavailable names warn and skip without blocking delegation.
|
|
24
|
-
- Main selects Role and may override Model Class per task; omitted class uses shared `pi-subagent/delegateTask` assignment, initially `balanced`. Library callers select Role plus their own shared task ID.
|
|
24
|
+
- Main selects Role and may override Model Class and thinking level per task; omitted class uses shared `pi-subagent/delegateTask` assignment, initially `balanced`. Library callers select Role plus their own shared task ID.
|
|
25
25
|
- The selected profile resolves primary then fallback only before launch when a route, model, or thinking level is unavailable. If neither route is usable, launch rejects with `Run /task-models`; a started child is never retried by this package.
|
|
26
26
|
- Role config lives only in user `config/pi-subagent` directory; model routes live in shared `config/pi-task-models.json`; repository roles do not execute.
|
|
27
27
|
- Numbered Codex routes prefer Main's active account slot and explicitly load the multi-Codex child extension.
|
package/README.md
CHANGED
|
@@ -21,13 +21,13 @@ pi install npm:@henryqw/pi-subagent
|
|
|
21
21
|
|
|
22
22
|
| Surface | Type | Purpose |
|
|
23
23
|
| --- | --- | --- |
|
|
24
|
-
| `delegate_task` | tool | Start one isolated child for `role`, `task`, and optional `model` or `
|
|
24
|
+
| `delegate_task` | tool | Start one isolated child for `role`, `task`, and optional `model`, `modelClass`, or `thinking`. |
|
|
25
25
|
|
|
26
|
-
An explicit `model` (`provider/modelId`) overrides `modelClass` and resolves against the currently available text models; an unknown reference rejects with the list of available models. Thinking level defaults to `medium` when supported, otherwise the highest supported level.
|
|
26
|
+
An explicit `model` (`provider/modelId`) overrides `modelClass` and resolves against the currently available text models; an unknown reference rejects with the list of available models. Thinking level defaults to `medium` when supported, otherwise the highest supported level; a designated model with no usable level rejects before launch. An explicit `thinking` level participates in route resolution itself: routes that cannot honor it are skipped so fallback routes get considered, and delegation rejects when no route supports the level.
|
|
27
27
|
|
|
28
28
|
`modelClass` is `fast`, `balanced`, `frontier`, or `fav`. Omitted class uses the shared `pi-subagent/delegateTask` assignment, which defaults to `balanced`. Primary route is resolved against current scoped text models; fallback is tried only before launch. If no route is usable, delegation rejects with `Run /task-models`. A started child is never retried.
|
|
29
29
|
|
|
30
|
-
Main splits broad work into independent bounded tasks and keeps integration and cross-cutting decisions. Each `task` states its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation. Each call uses the least capable `modelClass` that can reliably complete its task. Independent sibling calls can run concurrently; concurrent edit tasks must own non-overlapping files. Up to
|
|
30
|
+
Main splits broad work into independent bounded tasks and keeps integration and cross-cutting decisions. Each `task` states its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation. Each call uses the least capable `modelClass` that can reliably complete its task. Independent sibling calls can run concurrently; concurrent edit tasks must own non-overlapping files. Up to five active ephemeral `delegate_task` subagents run per Main; excess calls wait FIFO. Configure the cap with `"maxSubagents"` (positive integer) in `~/.pi/agent/config/pi-subagent.json`, or override per session with the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable. Child timeouts are configurable with a `"timeout"` object: `{ "softMinutes": 20, "graceMinutes": 10, "activeWindowSeconds": 90 }` (all keys optional; defaults 10/5/60). Invalid config values fall back to the default with a warning; an invalid environment variable fails fast. Queued calls do not start a child or consume child timeout. Managed Herdr workers are unaffected.
|
|
31
31
|
|
|
32
32
|
Each call starts one isolated child (`pi --mode json -p --no-session`). Ambient extensions and Skills are off. Role/caller extensions load; those packages' tools and Skills auto-load, plus any extra `skills` names. Child uses the delegated working directory and Main's project approval. Abort kills the child process group. An inactive child times out after 10 minutes; current model/tool execution or activity in the last minute grants one 5-minute grace period, then the child stops. Streaming output is capped at 50 KiB. Unused JSON event types are discarded before payload buffering; consumed or unclassifiable events above 1 MiB fail delegation.
|
|
33
33
|
|
package/dist/index.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface ResolvedRoleSkills {
|
|
|
35
35
|
}
|
|
36
36
|
export declare const isProfileName: (value: unknown) => value is ProfileName;
|
|
37
37
|
export declare function loadRoles(agentDir?: string): Role[];
|
|
38
|
-
export declare function resolveTaskRoute(ctx: ExtensionContext, profileName: ProfileName, agentDir?: string): ResolvedTaskRoute;
|
|
38
|
+
export declare function resolveTaskRoute(ctx: ExtensionContext, profileName: ProfileName, agentDir?: string, thinking?: ThinkingLevel): ResolvedTaskRoute;
|
|
39
39
|
export declare function resolveRoleSkills(pi: Pick<ExtensionAPI, "getCommands">, role: Role): ResolvedRoleSkills;
|
|
40
40
|
export declare function createRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: Pick<ExtensionContext, "isProjectTrusted">, input: CreateRoleLaunchInput): ResolvedRoleLaunch;
|
|
41
41
|
export declare function resolveRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: ExtensionContext, input: ResolveRoleLaunchInput): ResolvedRoleLaunch;
|
package/dist/index.js
CHANGED
|
@@ -81,7 +81,7 @@ export function loadRoles(agentDir = getAgentDir()) {
|
|
|
81
81
|
}
|
|
82
82
|
return roles;
|
|
83
83
|
}
|
|
84
|
-
export function resolveTaskRoute(ctx, profileName, agentDir = getAgentDir()) {
|
|
84
|
+
export function resolveTaskRoute(ctx, profileName, agentDir = getAgentDir(), thinking) {
|
|
85
85
|
let config;
|
|
86
86
|
try {
|
|
87
87
|
config = readTaskModelsConfig(agentDir);
|
|
@@ -89,17 +89,17 @@ export function resolveTaskRoute(ctx, profileName, agentDir = getAgentDir()) {
|
|
|
89
89
|
catch {
|
|
90
90
|
throw new Error("Couldn't read task model config. Run /task-models.");
|
|
91
91
|
}
|
|
92
|
-
return resolveConfiguredRoute(ctx, profileName, config.profiles[profileName], agentDir);
|
|
92
|
+
return resolveConfiguredRoute(ctx, profileName, config.profiles[profileName], agentDir, thinking);
|
|
93
93
|
}
|
|
94
|
-
function resolveConfiguredRoute(ctx, profileName, profile, agentDir = getAgentDir()) {
|
|
94
|
+
function resolveConfiguredRoute(ctx, profileName, profile, agentDir = getAgentDir(), thinking) {
|
|
95
95
|
if (!profile)
|
|
96
96
|
throw new Error(`No ${profileName} task model profile is configured. Run /task-models.`);
|
|
97
97
|
for (const route of orderedProfileRoutes(profile)) {
|
|
98
|
-
const resolved = resolveTaskModelRoute(ctx, route, agentDir);
|
|
98
|
+
const resolved = resolveTaskModelRoute(ctx, route, agentDir, thinking);
|
|
99
99
|
if (resolved)
|
|
100
100
|
return resolved;
|
|
101
101
|
}
|
|
102
|
-
throw new Error(`No usable ${profileName} task model route. Run /task-models.`);
|
|
102
|
+
throw new Error(`No usable ${profileName} task model route${thinking ? ` supporting thinking ${thinking}` : ""}. Run /task-models.`);
|
|
103
103
|
}
|
|
104
104
|
export function resolveRoleSkills(pi, role) {
|
|
105
105
|
const skills = new Map(pi.getCommands()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export interface SubagentTimeoutConfig {
|
|
6
|
+
/** Soft deadline in minutes; the child is asked to stop after this. */
|
|
7
|
+
softMinutes?: number;
|
|
8
|
+
/** Extra minutes past the soft deadline before a stuck child is killed. */
|
|
9
|
+
graceMinutes?: number;
|
|
10
|
+
/** Activity window in seconds that qualifies an active child for grace. */
|
|
11
|
+
activeWindowSeconds?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SubagentConfig {
|
|
15
|
+
maxSubagents?: number;
|
|
16
|
+
timeout?: SubagentTimeoutConfig;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LoadedSubagentConfig {
|
|
20
|
+
config: SubagentConfig;
|
|
21
|
+
/** Human-readable problems when the file exists but is partly unusable; the file is never rewritten. */
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const positive = (value: unknown): value is number =>
|
|
26
|
+
typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
27
|
+
|
|
28
|
+
// Node clamps setTimeout delays above 2^31 - 1 ms to 1 ms, which would kill
|
|
29
|
+
// every child immediately instead of applying the configured deadline.
|
|
30
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
31
|
+
// Defaults for fields the user left unset, used when bounding the combined
|
|
32
|
+
// soft + grace hard-deadline delay.
|
|
33
|
+
const DEFAULT_SOFT_MINUTES = 10;
|
|
34
|
+
const DEFAULT_GRACE_MINUTES = 5;
|
|
35
|
+
const TIMEOUT_FIELDS: Array<[keyof SubagentTimeoutConfig, number, string]> = [
|
|
36
|
+
["softMinutes", 60_000, "minutes"],
|
|
37
|
+
["graceMinutes", 60_000, "minutes"],
|
|
38
|
+
["activeWindowSeconds", 1_000, "seconds"],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Required single-extension config path form (AGENTS.md); agentDir is
|
|
43
|
+
* injectable so tests can point at a temp directory.
|
|
44
|
+
*/
|
|
45
|
+
export const configPath = (agentDir = getAgentDir()): string =>
|
|
46
|
+
join(agentDir, "config", "pi-subagent.json");
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read the optional user config at `<agentDir>/config/pi-subagent.json`.
|
|
50
|
+
* Treated as untrusted user data: malformed files are preserved untouched and
|
|
51
|
+
* reported instead of crashing the session; callers fall back to defaults.
|
|
52
|
+
*/
|
|
53
|
+
export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConfig {
|
|
54
|
+
const path = configPath(agentDir);
|
|
55
|
+
let raw: string;
|
|
56
|
+
try {
|
|
57
|
+
raw = readFileSync(path, "utf8");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { config: {} };
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
let parsed: unknown;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(raw);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return {
|
|
67
|
+
config: {},
|
|
68
|
+
error: `${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}); using defaults.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
72
|
+
return { config: {}, error: `${path} must contain a JSON object; using defaults.` };
|
|
73
|
+
}
|
|
74
|
+
const record = parsed as Record<string, unknown>;
|
|
75
|
+
const problems: string[] = [];
|
|
76
|
+
const config: SubagentConfig = {};
|
|
77
|
+
for (const key of Object.keys(record)) {
|
|
78
|
+
if (key !== "maxSubagents" && key !== "timeout") {
|
|
79
|
+
problems.push(`unknown config key ${JSON.stringify(key)}; expected maxSubagents, timeout`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (record.maxSubagents !== undefined) {
|
|
84
|
+
if (typeof record.maxSubagents === "number" && Number.isSafeInteger(record.maxSubagents) && record.maxSubagents >= 1) {
|
|
85
|
+
config.maxSubagents = record.maxSubagents;
|
|
86
|
+
} else {
|
|
87
|
+
problems.push(`maxSubagents must be a safe integer >= 1, got ${JSON.stringify(record.maxSubagents)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (record.timeout !== undefined) {
|
|
92
|
+
if (!record.timeout || typeof record.timeout !== "object" || Array.isArray(record.timeout)) {
|
|
93
|
+
problems.push(`timeout must be a JSON object, got ${JSON.stringify(record.timeout)}`);
|
|
94
|
+
} else {
|
|
95
|
+
const timeoutRecord = record.timeout as Record<string, unknown>;
|
|
96
|
+
const timeout: SubagentTimeoutConfig = {};
|
|
97
|
+
for (const [key, unitMs, unit] of TIMEOUT_FIELDS) {
|
|
98
|
+
const value = timeoutRecord[key];
|
|
99
|
+
if (value === undefined) continue;
|
|
100
|
+
if (!positive(value)) {
|
|
101
|
+
problems.push(`timeout.${key} must be a positive number of ${unit}, got ${JSON.stringify(value)}`);
|
|
102
|
+
} else if (value * unitMs > MAX_TIMER_DELAY_MS) {
|
|
103
|
+
problems.push(`timeout.${key} exceeds the maximum supported delay of ${MAX_TIMER_DELAY_MS} ms, got ${JSON.stringify(value)} ${unit}`);
|
|
104
|
+
} else {
|
|
105
|
+
(timeout as Record<string, number>)[key] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const key of Object.keys(timeoutRecord)) {
|
|
109
|
+
if (!TIMEOUT_FIELDS.some(([known]) => known === key)) {
|
|
110
|
+
problems.push(`unknown timeout.${key}; expected ${TIMEOUT_FIELDS.map(([known]) => known).join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (Object.keys(timeout).length) config.timeout = timeout;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The hard deadline schedules softMs + graceMs in one timer; the combination
|
|
118
|
+
// must stay inside Node's limit even when each field fits alone. Falling back
|
|
119
|
+
// to defaults drops the whole timeout object, matching other invalid values.
|
|
120
|
+
if (config.timeout) {
|
|
121
|
+
const hardDeadlineMinutes =
|
|
122
|
+
(config.timeout.softMinutes ?? DEFAULT_SOFT_MINUTES) +
|
|
123
|
+
(config.timeout.graceMinutes ?? DEFAULT_GRACE_MINUTES);
|
|
124
|
+
if (hardDeadlineMinutes * 60_000 > MAX_TIMER_DELAY_MS) {
|
|
125
|
+
problems.push(`timeout softMinutes + graceMinutes must stay within ${MAX_TIMER_DELAY_MS} ms combined, got ${hardDeadlineMinutes} minutes`);
|
|
126
|
+
delete config.timeout;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { config, error: problems.length ? `${path}: ${problems.join("; ")}; using defaults.` : undefined };
|
|
131
|
+
}
|
package/extensions/subagent.ts
CHANGED
|
@@ -4,23 +4,26 @@ import { basename } from "node:path";
|
|
|
4
4
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
5
|
import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
|
|
7
8
|
import {
|
|
8
9
|
availableTaskModels,
|
|
10
|
+
THINKING_LEVELS,
|
|
11
|
+
type ThinkingLevel,
|
|
9
12
|
modelReference,
|
|
10
13
|
PROFILE_NAMES,
|
|
11
14
|
type ProfileName,
|
|
12
15
|
resolveAvailableModel,
|
|
16
|
+
resolveConfiguredTaskRoute,
|
|
13
17
|
type ResolvedTaskRoute,
|
|
14
18
|
taskThinkingLevels,
|
|
15
19
|
} from "@henryqw/pi-task-models";
|
|
16
20
|
import { Type } from "typebox";
|
|
17
|
-
import { createRoleLaunch, isProfileName, loadRoles,
|
|
21
|
+
import { createRoleLaunch, isProfileName, loadRoles, resolveTaskRoute } from "@henryqw/pi-subagent";
|
|
18
22
|
|
|
19
23
|
const MODEL_CLASSES = PROFILE_NAMES;
|
|
20
24
|
const SUBAGENT_TASK = "pi-subagent/delegateTask";
|
|
21
25
|
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
22
26
|
const MAX_JSON_EVENT_BYTES = 1024 * 1024;
|
|
23
|
-
const MAX_ACTIVE_CHILDREN = 4;
|
|
24
27
|
const CONSUMED_JSON_EVENTS = new Set(["message_start", "message_update", "message_end"]);
|
|
25
28
|
const JSON_EVENT_TYPE = /^\s*\{\s*"type"\s*:\s*"([^"\\]+)"/;
|
|
26
29
|
const WIDGET_KEY = "subagent-status";
|
|
@@ -35,6 +38,16 @@ const DEFAULT_TIMEOUT_POLICY = {
|
|
|
35
38
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
36
39
|
|
|
37
40
|
type TimeoutPolicy = typeof DEFAULT_TIMEOUT_POLICY;
|
|
41
|
+
|
|
42
|
+
/** Merge validated config-file timeout fields over defaults; absent keys keep defaults. */
|
|
43
|
+
export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined): TimeoutPolicy {
|
|
44
|
+
const policy: TimeoutPolicy = { ...DEFAULT_TIMEOUT_POLICY };
|
|
45
|
+
if (!partial) return policy;
|
|
46
|
+
if (partial.softMinutes !== undefined) policy.softMs = partial.softMinutes * 60_000;
|
|
47
|
+
if (partial.graceMinutes !== undefined) policy.graceMs = partial.graceMinutes * 60_000;
|
|
48
|
+
if (partial.activeWindowSeconds !== undefined) policy.activeWindowMs = partial.activeWindowSeconds * 1_000;
|
|
49
|
+
return policy;
|
|
50
|
+
}
|
|
38
51
|
type ModelClass = ProfileName;
|
|
39
52
|
class SubagentTimeoutError extends Error {}
|
|
40
53
|
type ChildResult = {
|
|
@@ -429,18 +442,35 @@ const Parameters = Type.Object({
|
|
|
429
442
|
modelClass: Type.Optional(StringEnum(MODEL_CLASSES, {
|
|
430
443
|
description: "Classify task complexity: fast for narrow lookups or mechanical edits; balanced for normal bounded work; frontier for ambiguous, cross-cutting, or high-risk reasoning; fav for the user's favorite model when they ask for it. Defaults to the shared pi-subagent/delegateTask assignment.",
|
|
431
444
|
})),
|
|
445
|
+
background: Type.Optional(Type.Boolean({
|
|
446
|
+
description: "Run without blocking: returns a task ID immediately and delivers the outcome as a message when the Subagent settles; the result cannot be waited on. Set only when the user explicitly asks for non-blocking delegation. Prefer blocking delegation whenever the parent needs the result to continue.",
|
|
447
|
+
})),
|
|
448
|
+
thinking: Type.Optional(StringEnum(THINKING_LEVELS, {
|
|
449
|
+
description: "Override the resolved route's thinking level (e.g. when the user asks for deeper or lighter reasoning). Must be supported by the resolved model.",
|
|
450
|
+
})),
|
|
432
451
|
});
|
|
433
452
|
|
|
434
|
-
function resolveDesignatedRoute(ctx: ExtensionContext, reference: string): ResolvedTaskRoute {
|
|
453
|
+
function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinking?: ThinkingLevel): ResolvedTaskRoute {
|
|
435
454
|
const models = availableTaskModels(ctx);
|
|
436
455
|
const model = resolveAvailableModel(models, reference, ctx.model?.provider);
|
|
437
456
|
if (!model) {
|
|
438
457
|
throw new Error(`Unknown delegate_task model: ${reference}. Available models: ${models.map((candidate) => modelReference(candidate)).join(", ") || "none"}.`);
|
|
439
458
|
}
|
|
440
459
|
const levels = taskThinkingLevels(ctx, model);
|
|
460
|
+
if (thinking !== undefined) {
|
|
461
|
+
if (!levels.includes(thinking)) {
|
|
462
|
+
throw new Error(`delegate_task thinking ${thinking} is not usable for ${modelReference(model)} in this session. Usable levels here: ${levels.join(", ") || "none"}.`);
|
|
463
|
+
}
|
|
464
|
+
return { model, thinkingLevel: thinking };
|
|
465
|
+
}
|
|
466
|
+
if (!levels.length) {
|
|
467
|
+
throw new Error(`${modelReference(model)} has no usable thinking level in this session; pick another model or adjust the scoped thinking pin.`);
|
|
468
|
+
}
|
|
441
469
|
return { model, thinkingLevel: levels.includes("medium") ? "medium" : levels.at(-1)! };
|
|
442
470
|
}
|
|
443
471
|
|
|
472
|
+
const BACKGROUND_RESULT_TYPE = "subagent-background-result";
|
|
473
|
+
|
|
444
474
|
const roleSummary = (): string => {
|
|
445
475
|
try {
|
|
446
476
|
const roles = loadRoles();
|
|
@@ -452,14 +482,47 @@ const roleSummary = (): string => {
|
|
|
452
482
|
|
|
453
483
|
export default function subagentExtension(
|
|
454
484
|
pi: ExtensionAPI,
|
|
455
|
-
|
|
485
|
+
overrideTimeoutPolicy?: TimeoutPolicy,
|
|
456
486
|
): void {
|
|
457
487
|
const widgetItems = new Map<string, WidgetItem>();
|
|
488
|
+
// Each child is a full Pi process issuing its own model calls; cap parallel
|
|
489
|
+
// spend. Precedence: PI_SUBAGENT_MAX_SUBAGENTS env > config/pi-subagent.json
|
|
490
|
+
// maxSubagents > default 5. Invalid config falls back to the default and is
|
|
491
|
+
// reported once the UI exists; an invalid env value fails fast.
|
|
492
|
+
const loadedConfig = readSubagentConfig();
|
|
493
|
+
const startupWarnings = [loadedConfig.error].filter((message): message is string => message !== undefined);
|
|
494
|
+
let maxActiveSubagents = loadedConfig.config.maxSubagents ?? 5;
|
|
495
|
+
const maxSubagentsRaw = process.env.PI_SUBAGENT_MAX_SUBAGENTS;
|
|
496
|
+
if (maxSubagentsRaw !== undefined) {
|
|
497
|
+
// Reject "2workers", "1.5", "1e3" — parseInt would silently accept prefixes —
|
|
498
|
+
// and digit strings that overflow to Infinity, which would disable the cap.
|
|
499
|
+
if (!/^\d+$/.test(maxSubagentsRaw) || !/^[1-9]\d*$/.test(maxSubagentsRaw)) {
|
|
500
|
+
throw new Error(`PI_SUBAGENT_MAX_SUBAGENTS must be a positive integer, got ${JSON.stringify(maxSubagentsRaw)}.`);
|
|
501
|
+
}
|
|
502
|
+
const parsed = Number.parseInt(maxSubagentsRaw, 10);
|
|
503
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
504
|
+
throw new Error(`PI_SUBAGENT_MAX_SUBAGENTS exceeds the supported range, got ${JSON.stringify(maxSubagentsRaw)}.`);
|
|
505
|
+
}
|
|
506
|
+
maxActiveSubagents = parsed;
|
|
507
|
+
}
|
|
508
|
+
let backgroundSequence = 0;
|
|
509
|
+
// Explicit policy argument (tests/embedders) wins; otherwise resolve from
|
|
510
|
+
// config file over defaults.
|
|
511
|
+
const timeoutPolicy: TimeoutPolicy = overrideTimeoutPolicy ?? resolveTimeoutPolicy(loadedConfig.config.timeout);
|
|
512
|
+
// Background children outlive the launching tool call, so they get their own
|
|
513
|
+
// abort signal: tied to the session, not to the turn that started them.
|
|
514
|
+
const backgroundTasks = new Map<string, AbortController>();
|
|
515
|
+
// Latest known session context; refreshed on session lifecycle and model
|
|
516
|
+
// changes so queued background launches resolve against effective state.
|
|
517
|
+
let latestCtx: ExtensionContext | undefined;
|
|
518
|
+
// Bumped by session_start and session_shutdown; background tasks may only
|
|
519
|
+
// deliver into the exact session that launched them.
|
|
520
|
+
let sessionEpoch = 0;
|
|
458
521
|
let activeChildren = 0;
|
|
459
522
|
const queuedChildren: Array<() => void> = [];
|
|
460
523
|
const acquireChildPermit = (signal: AbortSignal | undefined): Promise<void> => {
|
|
461
524
|
if (signal?.aborted) return Promise.reject(new Error("Subagent was aborted."));
|
|
462
|
-
if (activeChildren <
|
|
525
|
+
if (activeChildren < maxActiveSubagents) {
|
|
463
526
|
activeChildren++;
|
|
464
527
|
return Promise.resolve();
|
|
465
528
|
}
|
|
@@ -561,7 +624,10 @@ export default function subagentExtension(
|
|
|
561
624
|
};
|
|
562
625
|
|
|
563
626
|
pi.on("session_start", (_event, ctx) => {
|
|
627
|
+
sessionEpoch += 1;
|
|
628
|
+
latestCtx = ctx;
|
|
564
629
|
ensureWidget(ctx);
|
|
630
|
+
for (const warning of startupWarnings.splice(0)) ctx.ui.notify(warning, "warning");
|
|
565
631
|
});
|
|
566
632
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
567
633
|
stopWidgetTimer();
|
|
@@ -569,18 +635,54 @@ export default function subagentExtension(
|
|
|
569
635
|
activeTui = undefined;
|
|
570
636
|
widgetInstalled = false;
|
|
571
637
|
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
638
|
+
// Invalidate every outstanding background delivery: the aborting session
|
|
639
|
+
// is gone, and a later-settling child must not reach the next session.
|
|
640
|
+
sessionEpoch += 1;
|
|
641
|
+
for (const controller of backgroundTasks.values()) controller.abort();
|
|
642
|
+
backgroundTasks.clear();
|
|
643
|
+
});
|
|
644
|
+
// btw-style context refresh: model_select carries the new model on the event,
|
|
645
|
+
// agent_settled delivers the freshest full context after each turn.
|
|
646
|
+
pi.on("model_select", (event, ctx) => {
|
|
647
|
+
latestCtx = { ...ctx, model: event.model } as ExtensionContext;
|
|
572
648
|
});
|
|
649
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
650
|
+
latestCtx = ctx;
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const reportBackground = async (
|
|
654
|
+
launchEpoch: number,
|
|
655
|
+
taskId: string,
|
|
656
|
+
details: { role: string; model?: string; thinkingLevel?: string },
|
|
657
|
+
outcome: "completed" | "failed" | "aborted",
|
|
658
|
+
text: string,
|
|
659
|
+
): Promise<void> => {
|
|
660
|
+
if (launchEpoch !== sessionEpoch) return;
|
|
661
|
+
// Custom messages convert to user-role LLM messages, so the parent agent
|
|
662
|
+
// sees the outcome on its next turn without a forced turn now.
|
|
663
|
+
try {
|
|
664
|
+
pi.sendMessage({
|
|
665
|
+
customType: BACKGROUND_RESULT_TYPE,
|
|
666
|
+
content: `Background subagent ${taskId} (${details.role}) ${outcome}.\n\n${capOutput(text)}`,
|
|
667
|
+
display: true,
|
|
668
|
+
details: { ...details, taskId, outcome },
|
|
669
|
+
}, { triggerTurn: false });
|
|
670
|
+
} catch {
|
|
671
|
+
// Session may already be gone; the widget row still shows the outcome.
|
|
672
|
+
}
|
|
673
|
+
};
|
|
573
674
|
|
|
574
675
|
pi.registerTool({
|
|
575
676
|
name: "delegate_task",
|
|
576
677
|
label: "Subagent",
|
|
577
|
-
description: `Delegate one bounded, independently executable task to one isolated Pi Subagent. Roles: ${roleSummary()}
|
|
678
|
+
description: `Delegate one bounded, independently executable task to one isolated Pi Subagent. Roles: ${roleSummary()}.`,
|
|
578
679
|
promptSnippet: "Delegate one bounded, independently executable task to an isolated role",
|
|
579
680
|
promptGuidelines: [
|
|
580
681
|
"Before calling delegate_task, split broad work into the smallest independent bounded tasks; keep integration and cross-cutting decisions in Main.",
|
|
581
682
|
"Each delegate_task task must state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; never pass the parent request unchanged.",
|
|
582
683
|
"Use fav when the user explicitly asks for their favorite model. Otherwise, choose the least capable modelClass that can reliably complete the task: fast for narrow work, balanced for normal work, and frontier only for ambiguous, cross-cutting, or high-risk work.",
|
|
583
684
|
"Submit independent delegate_task calls together for parallel execution. Parallel edits must own non-overlapping files; otherwise sequence them. Use the minimum number of Subagents needed.",
|
|
685
|
+
"Use background: true only when the user explicitly asks for non-blocking delegation (for example \"keep working while this runs\"); the result arrives as a message after the current turn and the parent must not assume it is available yet.",
|
|
584
686
|
],
|
|
585
687
|
parameters: Parameters,
|
|
586
688
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -594,28 +696,104 @@ export default function subagentExtension(
|
|
|
594
696
|
if (params.modelClass !== undefined && !isModelClass(params.modelClass)) {
|
|
595
697
|
throw new Error("delegate_task modelClass must be fast, balanced, frontier, or fav.");
|
|
596
698
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
699
|
+
// Resolve against the latest known session context: a task queued past
|
|
700
|
+
// the cap must pick up model or Codex account changes that happened
|
|
701
|
+
// while it waited.
|
|
702
|
+
const launchCtx = () => latestCtx ?? ctx;
|
|
703
|
+
// The explicit thinking override participates in route resolution itself:
|
|
704
|
+
// routes that cannot honor it are skipped so fallback routes get considered.
|
|
705
|
+
const resolveLaunch = () => createRoleLaunch(pi, launchCtx(), {
|
|
706
|
+
role,
|
|
707
|
+
route: params.model !== undefined
|
|
708
|
+
? resolveDesignatedRoute(launchCtx(), cleanText(params.model, "model", "delegate_task"), params.thinking)
|
|
709
|
+
: params.modelClass === undefined
|
|
710
|
+
? resolveConfiguredTaskRoute(launchCtx(), SUBAGENT_TASK, undefined, params.thinking)
|
|
711
|
+
: resolveTaskRoute(launchCtx(), params.modelClass, undefined, params.thinking),
|
|
712
|
+
});
|
|
713
|
+
const notifyMissingSkills = (launch: ReturnType<typeof resolveLaunch>) => {
|
|
714
|
+
if (launch.missingSkills.length) {
|
|
715
|
+
ctx.ui.notify(
|
|
716
|
+
`Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`,
|
|
717
|
+
"warning",
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
if (params.background) {
|
|
723
|
+
const taskId = `bg-${++backgroundSequence}-${Date.now().toString(36)}`;
|
|
724
|
+
const controller = new AbortController();
|
|
725
|
+
backgroundTasks.set(taskId, controller);
|
|
726
|
+
// Freeze the launching session now: a task that settles after a
|
|
727
|
+
// reload must not deliver into whichever session is active then.
|
|
728
|
+
const launchEpoch = sessionEpoch;
|
|
729
|
+
void (async () => {
|
|
730
|
+
let acquired = false;
|
|
731
|
+
let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
|
|
732
|
+
// Role is known up front; model/thinking join after launch resolution.
|
|
733
|
+
let details: { role: string; model?: string; thinkingLevel?: string } = { role: role.name };
|
|
734
|
+
try {
|
|
735
|
+
await acquireChildPermit(controller.signal);
|
|
736
|
+
acquired = true;
|
|
737
|
+
// Resolve resources only once launched: a task queued past the
|
|
738
|
+
// cap must not start with model routes or skills resolved before
|
|
739
|
+
// registries or accounts changed while it waited.
|
|
740
|
+
const launch = resolveLaunch();
|
|
741
|
+
notifyMissingSkills(launch);
|
|
742
|
+
details = { role: role.name, model: modelReference(launch.model), thinkingLevel: launch.thinkingLevel };
|
|
743
|
+
startWidgetItem(taskId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
|
|
744
|
+
const result = await runPi(
|
|
745
|
+
["--mode", "json", "-p", ...launch.args, `Task: ${task}`],
|
|
746
|
+
ctx.cwd,
|
|
747
|
+
controller.signal,
|
|
748
|
+
undefined,
|
|
749
|
+
(tokens) => updateWidgetTokens(taskId, tokens),
|
|
750
|
+
timeoutPolicy,
|
|
751
|
+
);
|
|
752
|
+
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
753
|
+
widgetStatus = result.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
|
|
754
|
+
const text = capOutput(failed
|
|
755
|
+
? result.errorMessage || result.stderr.trim() || result.output || `Subagent exited with code ${result.exitCode}.`
|
|
756
|
+
: result.output || "(no output)");
|
|
757
|
+
await reportBackground(
|
|
758
|
+
launchEpoch,
|
|
759
|
+
taskId,
|
|
760
|
+
details,
|
|
761
|
+
result.stopReason === "aborted" ? "aborted" : failed ? "failed" : "completed",
|
|
762
|
+
text,
|
|
763
|
+
);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
const aborted = controller.signal.aborted && !(error instanceof SubagentTimeoutError);
|
|
766
|
+
widgetStatus = aborted ? "aborted" : "failure";
|
|
767
|
+
await reportBackground(
|
|
768
|
+
launchEpoch,
|
|
769
|
+
taskId,
|
|
770
|
+
details,
|
|
771
|
+
aborted ? "aborted" : "failed",
|
|
772
|
+
capOutput(error instanceof Error ? error.message : String(error)),
|
|
773
|
+
);
|
|
774
|
+
} finally {
|
|
775
|
+
if (acquired) releaseChildPermit();
|
|
776
|
+
finishWidgetItem(taskId, widgetStatus);
|
|
777
|
+
backgroundTasks.delete(taskId);
|
|
778
|
+
}
|
|
779
|
+
})();
|
|
780
|
+
return {
|
|
781
|
+
content: [{ type: "text" as const, text: `Background subagent ${taskId} started (${role.name}). The outcome arrives as a message when the task settles; keep working or end your turn.` }],
|
|
782
|
+
details: { role: role.name, taskId, background: true },
|
|
783
|
+
};
|
|
609
784
|
}
|
|
610
785
|
|
|
611
|
-
const
|
|
786
|
+
const launch = resolveLaunch();
|
|
787
|
+
notifyMissingSkills(launch);
|
|
788
|
+
const modelReferenceValue = modelReference(launch.model);
|
|
789
|
+
const details = { role: role.name, model: modelReferenceValue, thinkingLevel: launch.thinkingLevel };
|
|
790
|
+
|
|
612
791
|
await acquireChildPermit(signal);
|
|
613
792
|
let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
|
|
614
793
|
try {
|
|
615
|
-
startWidgetItem(toolCallId, role.name, launch.model.id, thinkingLevel, task, ctx);
|
|
616
|
-
const details = { role: role.name, model: modelReferenceValue, thinkingLevel };
|
|
794
|
+
startWidgetItem(toolCallId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
|
|
617
795
|
const result = await runPi(
|
|
618
|
-
args,
|
|
796
|
+
["--mode", "json", "-p", ...launch.args, `Task: ${task}`],
|
|
619
797
|
ctx.cwd,
|
|
620
798
|
signal,
|
|
621
799
|
(text) => onUpdate?.({ content: [{ type: "text", text }], details }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-subagent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@henryqw/pi-herdr": "^0.1.1",
|
|
61
61
|
"@henryqw/pi-multi-codex": "^0.3.8",
|
|
62
|
-
"@henryqw/pi-task-models": "^0.
|
|
62
|
+
"@henryqw/pi-task-models": "^0.6.0"
|
|
63
63
|
}
|
|
64
64
|
}
|