@bacnh85/pi-subagent 0.15.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +53 -14
- package/agent-format.md +22 -6
- package/agents/general-purpose.md +1 -5
- package/agents/planner.md +1 -4
- package/agents/reviewer.md +1 -4
- package/agents/scout.md +1 -4
- package/agents/tester.md +1 -4
- package/agents/worker.md +1 -5
- package/extensions/agents.ts +2 -0
- package/extensions/background.ts +13 -0
- package/extensions/history.ts +5 -2
- package/extensions/index.ts +214 -125
- package/extensions/model.ts +94 -8
- package/extensions/roles-panel.ts +154 -0
- package/extensions/roles.ts +263 -0
- package/extensions/runner.ts +9 -3
- package/extensions/security.ts +87 -3
- package/extensions/service.ts +56 -71
- package/package.json +13 -2
package/extensions/security.ts
CHANGED
|
@@ -65,10 +65,57 @@ export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
|
|
|
65
65
|
export const EXECUTION_TOOLS: readonly string[] = ["bash"];
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* Generic warning sink for configuration problems. Stderr so it is visible
|
|
69
|
+
* with or without a TUI attached. Warnings are also buffered so the extension
|
|
70
|
+
* host can surface them as TUI notifications — the interactive TUI swallows
|
|
71
|
+
* module-load stderr, so module-load warnings alone are invisible in-TUI.
|
|
70
72
|
*/
|
|
71
|
-
|
|
73
|
+
const warnings: string[] = [];
|
|
74
|
+
function warn(message: string): void {
|
|
75
|
+
process.stderr.write(`[pi-subagent] ${message}\n`);
|
|
76
|
+
warnings.push(message);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Flush env-var config warnings collected at module load. Called once from
|
|
81
|
+
* the /subagent tool handler so the warnings surface as TUI notifications
|
|
82
|
+
* (module-load stderr is not visible inside the interactive TUI).
|
|
83
|
+
*/
|
|
84
|
+
export function flushWarnings(): string[] {
|
|
85
|
+
return warnings.splice(0, warnings.length);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a numeric value from an env var, with validation:
|
|
90
|
+
* - undefined/empty -> defaultValue (no warning)
|
|
91
|
+
* - not a finite number (NaN) -> defaultValue + warning
|
|
92
|
+
* - below `min` -> `min` + warning
|
|
93
|
+
* - above `max` -> `max` + warning
|
|
94
|
+
* - otherwise -> the parsed value (no warning)
|
|
95
|
+
*/
|
|
96
|
+
export function getNumericEnvVar(
|
|
97
|
+
envVar: string,
|
|
98
|
+
defaultValue: number,
|
|
99
|
+
min: number,
|
|
100
|
+
max: number,
|
|
101
|
+
): number {
|
|
102
|
+
const raw = process.env[envVar];
|
|
103
|
+
if (raw === undefined || raw === "") return defaultValue;
|
|
104
|
+
const value = Number(raw);
|
|
105
|
+
if (!Number.isFinite(value)) {
|
|
106
|
+
warn(`ENV VAR: ${envVar}="${raw}" is not a number; using default ${defaultValue}.`);
|
|
107
|
+
return defaultValue;
|
|
108
|
+
}
|
|
109
|
+
if (value < min) {
|
|
110
|
+
warn(`ENV VAR: ${envVar}=${value} is below the minimum of ${min}; using ${min}.`);
|
|
111
|
+
return min;
|
|
112
|
+
}
|
|
113
|
+
if (value > max) {
|
|
114
|
+
warn(`ENV VAR: ${envVar}=${value} is above the maximum of ${max}; using ${max}.`);
|
|
115
|
+
return max;
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
72
119
|
|
|
73
120
|
/**
|
|
74
121
|
* Absolute maximum timeout. Any requested value above this cap is rejected
|
|
@@ -76,6 +123,35 @@ export const DEFAULT_TIMEOUT_MS = 3 * 60 * 1_000; // 3 minutes
|
|
|
76
123
|
*/
|
|
77
124
|
export const MAX_TIMEOUT_MS = 60 * 60 * 1_000; // 60 minutes
|
|
78
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Timeout caps, in minutes, shared by getNumericEnvVar for both env vars.
|
|
128
|
+
* No timeout may be shorter than 1 minute; the inactivity window may not
|
|
129
|
+
* exceed the 60-minute absolute maximum.
|
|
130
|
+
*/
|
|
131
|
+
const MIN_TIMEOUT_MINS = 1;
|
|
132
|
+
const MAX_TIMEOUT_MINS = MAX_TIMEOUT_MS / 60_000;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Default inactivity timeout. Real SDK lifecycle activity resets this window;
|
|
136
|
+
* the runner enforces a fixed absolute cap (HARD_TIMEOUT_MS).
|
|
137
|
+
*
|
|
138
|
+
* Reads PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS (default 3 min, range 1–60) and
|
|
139
|
+
* PI_SUBAGENT_HARD_TIMEOUT_MINS (default 20 min, range 1–60) from env via
|
|
140
|
+
* getNumericEnvVar, which warns on invalid/out-of-range values. The hard cap
|
|
141
|
+
* is additionally clamped up to the inactivity window: a lifetime cap smaller
|
|
142
|
+
* than the idle window is nonsensical.
|
|
143
|
+
*/
|
|
144
|
+
const INACTIVITY_TIMEOUT_MINS = getNumericEnvVar("PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS", 3, MIN_TIMEOUT_MINS, MAX_TIMEOUT_MINS);
|
|
145
|
+
let hardTimeoutMins = getNumericEnvVar("PI_SUBAGENT_HARD_TIMEOUT_MINS", 20, MIN_TIMEOUT_MINS, MAX_TIMEOUT_MINS);
|
|
146
|
+
if (hardTimeoutMins < INACTIVITY_TIMEOUT_MINS) {
|
|
147
|
+
warn(
|
|
148
|
+
`PI_SUBAGENT_HARD_TIMEOUT_MINS (${hardTimeoutMins}) is below the inactivity window (${INACTIVITY_TIMEOUT_MINS}); raising hard cap to ${INACTIVITY_TIMEOUT_MINS}.`,
|
|
149
|
+
);
|
|
150
|
+
hardTimeoutMins = INACTIVITY_TIMEOUT_MINS;
|
|
151
|
+
}
|
|
152
|
+
export const DEFAULT_TIMEOUT_MS = INACTIVITY_TIMEOUT_MINS * 60 * 1_000;
|
|
153
|
+
export const HARD_TIMEOUT_MS = hardTimeoutMins * 60 * 1_000;
|
|
154
|
+
|
|
79
155
|
// ---------------------------------------------------------------------------
|
|
80
156
|
// Canonical result status
|
|
81
157
|
// ---------------------------------------------------------------------------
|
|
@@ -489,6 +565,14 @@ export function validateExecutionRequest(
|
|
|
489
565
|
): ValidationError[] {
|
|
490
566
|
const errors: ValidationError[] = [];
|
|
491
567
|
|
|
568
|
+
// Timeout (single/parallel/chain share one requested timeout)
|
|
569
|
+
if (options.timeout !== undefined) {
|
|
570
|
+
const t = normalizeTimeout({ requested: options.timeout });
|
|
571
|
+
if (t.error) {
|
|
572
|
+
errors.push({ field: "timeout", message: t.error });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
492
576
|
// Agent name
|
|
493
577
|
if (options.agentName !== undefined) {
|
|
494
578
|
if (typeof options.agentName !== "string" || options.agentName.trim().length === 0) {
|
package/extensions/service.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { type AgentConfig
|
|
2
|
+
import { type AgentConfig } from "./agents.ts";
|
|
3
3
|
import { runSubAgent, type SubAgentProgress, type SubAgentResult } from "./runner.ts";
|
|
4
|
-
import { resolveModel } from "./model.ts";
|
|
4
|
+
import { resolveModel, runWithModelFallback } from "./model.ts";
|
|
5
|
+
import { readSubagentRoles, resolveAgentModelChain } from "./roles.ts";
|
|
5
6
|
import {
|
|
6
7
|
isRateLimitError,
|
|
7
8
|
validateAgentTools,
|
|
@@ -42,10 +43,15 @@ export async function runNamedAgent(options: {
|
|
|
42
43
|
signal?: AbortSignal;
|
|
43
44
|
/** When true, only read-only tools are permitted regardless of agent.sandbox. */
|
|
44
45
|
readOnly?: boolean;
|
|
46
|
+
/** Trusted opt-out for child cwd outside the workspace (from getTrustedConfig). */
|
|
47
|
+
allowExternalCwd?: boolean;
|
|
45
48
|
onMessage?: (result: SubAgentResult) => void;
|
|
46
49
|
onProgress?: (progress: SubAgentProgress) => void;
|
|
47
50
|
}): Promise<SubAgentResult> {
|
|
48
|
-
const
|
|
51
|
+
const rolesCfg = readSubagentRoles(options.ctx);
|
|
52
|
+
const agentChain = resolveAgentModelChain(options.agent, rolesCfg);
|
|
53
|
+
const resolvedModel = await resolveModel(agentChain.candidates, options.ctx.model, options.ctx.modelRegistry);
|
|
54
|
+
const { model, attempted } = resolvedModel;
|
|
49
55
|
if (!model) throw new Error(`No model resolved for agent "${options.agent.name}" (tried: ${attempted.join(", ") || "none"})`);
|
|
50
56
|
|
|
51
57
|
const modelRegistry = options.ctx.modelRegistry;
|
|
@@ -53,7 +59,11 @@ export async function runNamedAgent(options: {
|
|
|
53
59
|
const authStorage = (modelRegistry as any).authStorage;
|
|
54
60
|
|
|
55
61
|
// Security: validate and normalise timeout.
|
|
56
|
-
const
|
|
62
|
+
const timeoutResult = normalizeTimeout({ requested: options.timeout });
|
|
63
|
+
if (timeoutResult.error) {
|
|
64
|
+
throw new Error(timeoutResult.error);
|
|
65
|
+
}
|
|
66
|
+
const effectiveTimeoutMs = timeoutResult.timeoutMs;
|
|
57
67
|
|
|
58
68
|
// Parent tool names — agents without an explicit `tools` line inherit them.
|
|
59
69
|
const parentToolNames = (options.ctx as any).getAllTools?.()?.map((t: { name: string }) => t.name) as string[] | undefined;
|
|
@@ -77,80 +87,55 @@ export async function runNamedAgent(options: {
|
|
|
77
87
|
|
|
78
88
|
// Security: validate cwd (service caller must provide valid cwd).
|
|
79
89
|
// The service path uses the same policy as the tool path.
|
|
80
|
-
const safeCwd = resolveSafeCwd({ workspaceRoot: options.ctx.cwd, childCwd: options.cwd });
|
|
90
|
+
const safeCwd = resolveSafeCwd({ workspaceRoot: options.ctx.cwd, childCwd: options.cwd, allowExternalCwd: options.allowExternalCwd });
|
|
81
91
|
if (safeCwd.error) {
|
|
82
92
|
throw new Error(safeCwd.error);
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
const contract = options.instructions?.slice(0, MAX_INSTRUCTIONS_LENGTH);
|
|
86
96
|
|
|
87
|
-
// Retry loop: rate-limit model fallback
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
task: options.task,
|
|
123
|
-
tools: toolValidation.tools,
|
|
124
|
-
model: fallbackResolved.model,
|
|
125
|
-
modelRuntime,
|
|
126
|
-
authStorage,
|
|
127
|
-
modelRegistry,
|
|
128
|
-
signal: options.signal,
|
|
129
|
-
timeoutMs: effectiveTimeoutMs,
|
|
130
|
-
agentName: options.agent.name,
|
|
131
|
-
thinkingLevel: options.agent.thinking,
|
|
132
|
-
onMessage: options.onMessage,
|
|
133
|
-
onProgress: options.onProgress,
|
|
134
|
-
loadExtensions,
|
|
135
|
-
projectTrusted,
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
if (result.errorMessage && isRateLimitError(result.errorMessage)) {
|
|
139
|
-
// If the model that just rate-limited was the parent fallback
|
|
140
|
-
// (no remaining candidates), stop — no further options.
|
|
141
|
-
if (isParentFallback) {
|
|
97
|
+
// Retry loop: rate-limit model fallback — shared with the tool path so the
|
|
98
|
+
// triedModels bookkeeping and per-candidate `:thinking` resolution stay in
|
|
99
|
+
// one place (see runWithModelFallback in model.ts). Errors are thrown here;
|
|
100
|
+
// the caller maps them to its own response shape.
|
|
101
|
+
return runWithModelFallback<SubAgentResult>({
|
|
102
|
+
candidates: agentChain.candidates,
|
|
103
|
+
parentModel: options.ctx.model,
|
|
104
|
+
modelRegistry: options.ctx.modelRegistry,
|
|
105
|
+
thinkingByCandidate: agentChain.thinkingByCandidate,
|
|
106
|
+
defaultThinking: options.agent.thinking,
|
|
107
|
+
runAttempt: (model, thinkingLevel) =>
|
|
108
|
+
runSubAgent({
|
|
109
|
+
cwd: safeCwd.path,
|
|
110
|
+
sandbox: options.agent.sandbox === "worktree" ? "worktree" : undefined,
|
|
111
|
+
systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
|
|
112
|
+
task: options.task,
|
|
113
|
+
tools: toolValidation.tools,
|
|
114
|
+
model,
|
|
115
|
+
modelRuntime,
|
|
116
|
+
authStorage,
|
|
117
|
+
modelRegistry,
|
|
118
|
+
signal: options.signal,
|
|
119
|
+
timeoutMs: effectiveTimeoutMs,
|
|
120
|
+
agentName: options.agent.name,
|
|
121
|
+
thinkingLevel,
|
|
122
|
+
onMessage: options.onMessage,
|
|
123
|
+
onProgress: options.onProgress,
|
|
124
|
+
loadExtensions,
|
|
125
|
+
projectTrusted,
|
|
126
|
+
}),
|
|
127
|
+
isRateLimited: (result) => Boolean(result.errorMessage && isRateLimitError(result.errorMessage)),
|
|
128
|
+
onExhausted: (reason, triedModels, remaining) => {
|
|
129
|
+
const tried = triedModels.join(" → ") || "(none)";
|
|
130
|
+
const parent = options.ctx.model ? `${options.ctx.model.provider}/${options.ctx.model.id}` : "none";
|
|
131
|
+
if (reason === "no-model") {
|
|
142
132
|
throw new Error(
|
|
143
|
-
`All
|
|
133
|
+
`All models rate-limited or unavailable. Tried: ${tried}. ` +
|
|
134
|
+
`Remaining candidates: ${remaining.join(", ") || "none"}. ` +
|
|
135
|
+
`Parent: ${parent}.`,
|
|
144
136
|
);
|
|
145
137
|
}
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
return await tryWithFallback();
|
|
153
|
-
} finally {
|
|
154
|
-
// No manual timeout handling needed — runSubAgent handles timeouts internally.
|
|
155
|
-
}
|
|
138
|
+
throw new Error(`All available models exhausted. Tried: ${tried}.`);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
156
141
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"extensions/index.ts",
|
|
35
35
|
"extensions/agents.ts",
|
|
36
36
|
"extensions/model.ts",
|
|
37
|
+
"extensions/roles.ts",
|
|
38
|
+
"extensions/roles-panel.ts",
|
|
37
39
|
"extensions/runner.ts",
|
|
38
40
|
"extensions/service.ts",
|
|
39
41
|
"extensions/render.ts",
|
|
@@ -66,6 +68,9 @@
|
|
|
66
68
|
"@earendil-works/pi-tui": ">=0.80.0 <0.85.0",
|
|
67
69
|
"typebox": ">=1.3.0 <2.0.0"
|
|
68
70
|
},
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"@bacnh85/pi-config-panel": "^0.1.0"
|
|
73
|
+
},
|
|
69
74
|
"devDependencies": {
|
|
70
75
|
"@earendil-works/pi-agent-core": "^0.84.0",
|
|
71
76
|
"@earendil-works/pi-ai": "^0.84.0",
|
|
@@ -73,9 +78,15 @@
|
|
|
73
78
|
"@earendil-works/pi-tui": "^0.84.0",
|
|
74
79
|
"@types/mocha": "^10.0.10",
|
|
75
80
|
"@types/node": "^20.19.43",
|
|
76
|
-
"mocha": "^
|
|
81
|
+
"mocha": "^11.8.0",
|
|
77
82
|
"tsx": "^4.22.4",
|
|
78
83
|
"typescript": "^5.9.3",
|
|
79
84
|
"typebox": "^1.3.1"
|
|
85
|
+
},
|
|
86
|
+
"overrides": {
|
|
87
|
+
"serialize-javascript@>=5.0.0 <7.0.5": "^7.0.5",
|
|
88
|
+
"js-yaml@>=4.0.0 <4.3.1": "^4.3.1",
|
|
89
|
+
"brace-expansion@>=2.0.0 <2.1.4": "^2.1.4",
|
|
90
|
+
"diff": "^8.0.3"
|
|
80
91
|
}
|
|
81
92
|
}
|