@narumitw/pi-subagents 3.0.2 → 3.1.1
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/README.md +58 -49
- package/dist/child-communication-bridge.ts +7 -5
- package/dist/child-communication-bridge.ts.map +2 -2
- package/dist/child-readiness-probe.ts +68 -0
- package/dist/child-readiness-probe.ts.map +7 -0
- package/dist/chunks/chunk-3FCHXUKZ.ts +98 -0
- package/dist/chunks/chunk-3FCHXUKZ.ts.map +7 -0
- package/dist/chunks/{chunk-7JQOB375.ts → chunk-YMVSJ5GE.ts} +84 -132
- package/dist/chunks/{chunk-7JQOB375.ts.map → chunk-YMVSJ5GE.ts.map} +4 -4
- package/dist/chunks/chunk-ZOAFWLDJ.ts +130 -0
- package/dist/chunks/chunk-ZOAFWLDJ.ts.map +7 -0
- package/dist/index.ts +1195 -81
- package/dist/index.ts.map +4 -4
- package/docs/attachments.md +37 -0
- package/docs/design-principles.md +8 -2
- package/docs/messaging.md +23 -0
- package/docs/tools.md +22 -56
- package/package.json +8 -5
- package/src/attachment-utils.ts +104 -0
- package/src/broker-credentials.ts +106 -28
- package/src/child-communication-bridge.ts +3 -3
- package/src/child-communication-tools.ts +2 -24
- package/src/child-readiness-probe.ts +72 -0
- package/src/process.ts +391 -47
- package/src/resource-attachments.ts +724 -0
- package/src/runtime.ts +18 -1
- package/src/skill-attachments.ts +302 -0
- package/src/timeout.ts +24 -0
- package/src/tools.ts +96 -18
- package/src/types.ts +4 -0
package/src/runtime.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { COMPLETION_MESSAGE_TYPE } from "./completion-renderer.js";
|
|
|
3
3
|
import { type BrokerSendAcknowledgement, type MessageBroker, sanitizeTerminalText } from "./message-broker.js";
|
|
4
4
|
import { modelVisibleJson, requireBoundedModelText } from "./model-output.js";
|
|
5
5
|
import { runChild as defaultRunChild } from "./process.js";
|
|
6
|
+
import type { ExtensionAttachment } from "./resource-attachments.js";
|
|
6
7
|
import {
|
|
7
8
|
type ChildControl,
|
|
8
9
|
type ChildRequest,
|
|
@@ -56,6 +57,9 @@ export interface ActiveJobDisplay {
|
|
|
56
57
|
export interface StartJobInput {
|
|
57
58
|
task: string;
|
|
58
59
|
tools: string[];
|
|
60
|
+
skills: string[];
|
|
61
|
+
extensions: ExtensionAttachment[];
|
|
62
|
+
toolSources: Record<string, string[]>;
|
|
59
63
|
model: string;
|
|
60
64
|
thinkingLevel: SubagentThinkingLevel;
|
|
61
65
|
cwd: string;
|
|
@@ -93,6 +97,10 @@ export class SubagentRuntime {
|
|
|
93
97
|
this.notifyJobsChanged();
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
getSessionGeneration(): number {
|
|
101
|
+
return this.generation;
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
subscribeJobs(listener: () => void): () => void {
|
|
97
105
|
this.jobListeners.add(listener);
|
|
98
106
|
return () => this.jobListeners.delete(listener);
|
|
@@ -139,13 +147,14 @@ export class SubagentRuntime {
|
|
|
139
147
|
});
|
|
140
148
|
void controlReady.catch(() => undefined);
|
|
141
149
|
const controller = new AbortController();
|
|
150
|
+
const effectiveTools = [...new Set([...input.tools, ...input.extensions.flatMap((extension) => extension.tools)])];
|
|
142
151
|
const job: InternalJob = {
|
|
143
152
|
jobId,
|
|
144
153
|
state: "queued",
|
|
145
154
|
createdAt: this.now(),
|
|
146
155
|
...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
|
|
147
156
|
controller,
|
|
148
|
-
tools:
|
|
157
|
+
tools: effectiveTools,
|
|
149
158
|
terminal,
|
|
150
159
|
resolveTerminal,
|
|
151
160
|
controlReady,
|
|
@@ -172,6 +181,14 @@ export class SubagentRuntime {
|
|
|
172
181
|
child = await this.runChild({
|
|
173
182
|
task: input.task,
|
|
174
183
|
tools: [...input.tools],
|
|
184
|
+
skills: [...input.skills],
|
|
185
|
+
extensions: input.extensions.map((extension) => ({
|
|
186
|
+
path: extension.path,
|
|
187
|
+
tools: [...extension.tools],
|
|
188
|
+
})),
|
|
189
|
+
toolSources: Object.fromEntries(
|
|
190
|
+
Object.entries(input.toolSources).map(([tool, sources]) => [tool, [...sources]]),
|
|
191
|
+
),
|
|
175
192
|
model: input.model,
|
|
176
193
|
thinkingLevel: input.thinkingLevel,
|
|
177
194
|
cwd: input.cwd,
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { type Dirent, statSync } from "node:fs";
|
|
2
|
+
import { opendir as opendirAsync, realpath as realpathAsync, stat as statAsync } from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { loadSkills } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import ignore from "ignore";
|
|
6
|
+
import {
|
|
7
|
+
addAttachmentIgnoreRules,
|
|
8
|
+
type IgnoreMatcher,
|
|
9
|
+
isAbortError,
|
|
10
|
+
isWithin,
|
|
11
|
+
realpath,
|
|
12
|
+
throwIfAttachmentAborted,
|
|
13
|
+
toPosixPath,
|
|
14
|
+
} from "./attachment-utils.js";
|
|
15
|
+
|
|
16
|
+
export const MAX_SKILL_SCAN_DEPTH = 32;
|
|
17
|
+
export const MAX_SKILL_SCAN_ENTRIES = 4_096;
|
|
18
|
+
export const MAX_SKILL_SCAN_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
export const MAX_SKILL_IGNORE_BYTES = 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
export async function assertLoadableSkills(
|
|
22
|
+
skillPaths: string[],
|
|
23
|
+
cwd: string,
|
|
24
|
+
canonicalCwd: string,
|
|
25
|
+
projectTrusted: boolean,
|
|
26
|
+
signal?: AbortSignal,
|
|
27
|
+
): Promise<SkillScanState> {
|
|
28
|
+
const scanState: SkillScanState = {
|
|
29
|
+
entries: 0,
|
|
30
|
+
skillBytes: 0,
|
|
31
|
+
ignoreBytes: 0,
|
|
32
|
+
ancestors: new Set<string>(),
|
|
33
|
+
signal,
|
|
34
|
+
};
|
|
35
|
+
for (const skillPath of skillPaths) {
|
|
36
|
+
throwIfAttachmentAborted(signal);
|
|
37
|
+
const candidates = await collectBoundedSkillCandidates(skillPath, scanState);
|
|
38
|
+
throwIfAttachmentAborted(signal);
|
|
39
|
+
const result = loadExplicitSkills([skillPath], cwd);
|
|
40
|
+
if (result.skills.length === 0) {
|
|
41
|
+
throw new Error("Subagent skill path must contain at least one loadable Pi skill.");
|
|
42
|
+
}
|
|
43
|
+
assertNoUntrustedProjectSkills(result, cwd, canonicalCwd, projectTrusted);
|
|
44
|
+
assertNoOmittedSkillDiagnostics(result);
|
|
45
|
+
assertNoSkillNameCollisions(result.diagnostics);
|
|
46
|
+
await assertNoSilentlyOmittedSkills(skillPath, cwd, result, canonicalCwd, projectTrusted, candidates, signal);
|
|
47
|
+
}
|
|
48
|
+
if (skillPaths.length > 1) {
|
|
49
|
+
throwIfAttachmentAborted(signal);
|
|
50
|
+
const result = loadExplicitSkills(skillPaths, cwd);
|
|
51
|
+
assertNoUntrustedProjectSkills(result, cwd, canonicalCwd, projectTrusted);
|
|
52
|
+
assertNoSkillNameCollisions(result.diagnostics);
|
|
53
|
+
}
|
|
54
|
+
return scanState;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadExplicitSkills(skillPaths: string[], cwd: string): ReturnType<typeof loadSkills> {
|
|
58
|
+
return loadSkills({ cwd, agentDir: cwd, skillPaths, includeDefaults: false });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertNoOmittedSkillDiagnostics(result: ReturnType<typeof loadSkills>): void {
|
|
62
|
+
const loadedPaths = new Set(result.skills.map((skill) => skill.filePath));
|
|
63
|
+
const omitted = result.diagnostics.some(
|
|
64
|
+
(diagnostic) =>
|
|
65
|
+
diagnostic.type === "error" ||
|
|
66
|
+
(diagnostic.type === "warning" && (!diagnostic.path || !loadedPaths.has(diagnostic.path))),
|
|
67
|
+
);
|
|
68
|
+
if (omitted) {
|
|
69
|
+
throw new Error("Subagent skill attachment must not contain an invalid or unreadable declared skill.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertNoSkillNameCollisions(diagnostics: ReturnType<typeof loadSkills>["diagnostics"]): void {
|
|
74
|
+
if (
|
|
75
|
+
diagnostics.some((diagnostic) => diagnostic.type === "collision" && diagnostic.collision?.resourceType === "skill")
|
|
76
|
+
) {
|
|
77
|
+
throw new Error("Subagent skill attachments must not contain duplicate skill names.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertNoUntrustedProjectSkills(
|
|
82
|
+
result: ReturnType<typeof loadSkills>,
|
|
83
|
+
cwd: string,
|
|
84
|
+
canonicalCwd: string,
|
|
85
|
+
projectTrusted: boolean,
|
|
86
|
+
): void {
|
|
87
|
+
if (projectTrusted) return;
|
|
88
|
+
for (const skill of result.skills) {
|
|
89
|
+
const lexicalPath = path.resolve(skill.filePath);
|
|
90
|
+
const canonicalPath = realpath(skill.filePath, "Loaded subagent skill");
|
|
91
|
+
if (isWithin(cwd, lexicalPath) || isWithin(canonicalCwd, canonicalPath)) {
|
|
92
|
+
throw new Error("Subagent skill cannot load a project path because the project is not trusted.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function assertNoSilentlyOmittedSkills(
|
|
98
|
+
skillPath: string,
|
|
99
|
+
cwd: string,
|
|
100
|
+
result: ReturnType<typeof loadSkills>,
|
|
101
|
+
canonicalCwd: string,
|
|
102
|
+
projectTrusted: boolean,
|
|
103
|
+
candidates: string[],
|
|
104
|
+
signal?: AbortSignal,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
if (!statSync(skillPath).isDirectory()) return;
|
|
107
|
+
const loadedPaths = new Set(result.skills.map((skill) => realpath(skill.filePath, "Loaded subagent skill")));
|
|
108
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
109
|
+
if (index > 0 && index % 64 === 0) await new Promise<void>((resolve) => setImmediate(resolve));
|
|
110
|
+
throwIfAttachmentAborted(signal);
|
|
111
|
+
const candidateResult = loadExplicitSkills([candidate], cwd);
|
|
112
|
+
assertNoUntrustedProjectSkills(candidateResult, cwd, canonicalCwd, projectTrusted);
|
|
113
|
+
if (candidateResult.skills.length === 0) {
|
|
114
|
+
if (path.basename(candidate) === "SKILL.md") throwInvalidDeclaredSkill();
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const loaded = candidateResult.skills.some((skill) =>
|
|
118
|
+
loadedPaths.has(realpath(skill.filePath, "Loaded subagent skill")),
|
|
119
|
+
);
|
|
120
|
+
if (!loaded) throwInvalidDeclaredSkill();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface SkillScanState {
|
|
125
|
+
entries: number;
|
|
126
|
+
skillBytes: number;
|
|
127
|
+
ignoreBytes: number;
|
|
128
|
+
ancestors: Set<string>;
|
|
129
|
+
signal?: AbortSignal;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function collectBoundedSkillCandidates(skillPath: string, state: SkillScanState): Promise<string[]> {
|
|
133
|
+
throwIfAttachmentAborted(state.signal);
|
|
134
|
+
const stats = await statAsync(skillPath);
|
|
135
|
+
throwIfAttachmentAborted(state.signal);
|
|
136
|
+
if (!stats.isDirectory()) {
|
|
137
|
+
if (stats.isFile() && skillPath.endsWith(".md")) addSkillBytes(state, stats.size);
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
return collectSkillCandidates(skillPath, true, ignore(), skillPath, 0, state);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function collectSkillCandidates(
|
|
144
|
+
directory: string,
|
|
145
|
+
includeRootMarkdown: boolean,
|
|
146
|
+
ignoreMatcher: IgnoreMatcher,
|
|
147
|
+
rootDirectory: string,
|
|
148
|
+
depth: number,
|
|
149
|
+
state: SkillScanState,
|
|
150
|
+
): Promise<string[]> {
|
|
151
|
+
throwIfAttachmentAborted(state.signal);
|
|
152
|
+
if (depth > MAX_SKILL_SCAN_DEPTH) throwSkillScanLimit();
|
|
153
|
+
let canonicalDirectory: string;
|
|
154
|
+
try {
|
|
155
|
+
canonicalDirectory = await realpathAsync(directory);
|
|
156
|
+
} catch {
|
|
157
|
+
throwInvalidDeclaredSkill();
|
|
158
|
+
}
|
|
159
|
+
throwIfAttachmentAborted(state.signal);
|
|
160
|
+
if (state.ancestors.has(canonicalDirectory)) {
|
|
161
|
+
throw new Error("Subagent skill attachment must not contain a recursive directory link.");
|
|
162
|
+
}
|
|
163
|
+
state.ancestors.add(canonicalDirectory);
|
|
164
|
+
try {
|
|
165
|
+
await addAttachmentIgnoreRules(ignoreMatcher, directory, rootDirectory, state.signal, (_ignorePath, bytes) => {
|
|
166
|
+
state.ignoreBytes += bytes;
|
|
167
|
+
if (state.ignoreBytes > MAX_SKILL_IGNORE_BYTES) throwSkillScanLimit();
|
|
168
|
+
});
|
|
169
|
+
let directoryHandle: Awaited<ReturnType<typeof opendirAsync>>;
|
|
170
|
+
try {
|
|
171
|
+
directoryHandle = await opendirAsync(directory);
|
|
172
|
+
} catch {
|
|
173
|
+
throwInvalidDeclaredSkill();
|
|
174
|
+
}
|
|
175
|
+
const entries: Dirent[] = [];
|
|
176
|
+
try {
|
|
177
|
+
for await (const entry of directoryHandle) {
|
|
178
|
+
throwIfAttachmentAborted(state.signal);
|
|
179
|
+
state.entries++;
|
|
180
|
+
if (state.entries > MAX_SKILL_SCAN_ENTRIES) throwSkillScanLimit();
|
|
181
|
+
entries.push(entry);
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (isAbortError(error) || isSkillScanLimitError(error)) throw error;
|
|
185
|
+
throwInvalidDeclaredSkill();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const rootSkill = entries.find((entry) => entry.name === "SKILL.md");
|
|
189
|
+
if (rootSkill) {
|
|
190
|
+
const rootPath = path.join(directory, rootSkill.name);
|
|
191
|
+
const relativePath = toPosixPath(path.relative(rootDirectory, rootPath));
|
|
192
|
+
if (!ignoreMatcher.ignores(relativePath)) {
|
|
193
|
+
let rootStats: Awaited<ReturnType<typeof statAsync>>;
|
|
194
|
+
try {
|
|
195
|
+
rootStats = await statAsync(rootPath);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (isAbortError(error)) throw error;
|
|
198
|
+
throwInvalidDeclaredSkill();
|
|
199
|
+
}
|
|
200
|
+
throwIfAttachmentAborted(state.signal);
|
|
201
|
+
if (rootStats.isFile()) {
|
|
202
|
+
addSkillBytes(state, rootStats.size);
|
|
203
|
+
return [rootPath];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const candidates: string[] = [];
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
throwIfAttachmentAborted(state.signal);
|
|
211
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
212
|
+
const candidate = path.join(directory, entry.name);
|
|
213
|
+
const relativePath = toPosixPath(path.relative(rootDirectory, candidate));
|
|
214
|
+
let candidateStats: Awaited<ReturnType<typeof statAsync>>;
|
|
215
|
+
try {
|
|
216
|
+
candidateStats = await statAsync(candidate);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (isAbortError(error)) throw error;
|
|
219
|
+
throwIfAttachmentAborted(state.signal);
|
|
220
|
+
if (
|
|
221
|
+
!ignoreMatcher.ignores(relativePath) &&
|
|
222
|
+
(entry.name === "SKILL.md" || (includeRootMarkdown && entry.name.endsWith(".md")))
|
|
223
|
+
) {
|
|
224
|
+
throwInvalidDeclaredSkill();
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
throwIfAttachmentAborted(state.signal);
|
|
229
|
+
if (ignoreMatcher.ignores(candidateStats.isDirectory() ? `${relativePath}/` : relativePath)) continue;
|
|
230
|
+
if (candidateStats.isDirectory()) {
|
|
231
|
+
candidates.push(
|
|
232
|
+
...(await collectSkillCandidates(candidate, false, ignoreMatcher, rootDirectory, depth + 1, state)),
|
|
233
|
+
);
|
|
234
|
+
} else if (
|
|
235
|
+
candidateStats.isFile() &&
|
|
236
|
+
(entry.name === "SKILL.md" || (includeRootMarkdown && entry.name.endsWith(".md")))
|
|
237
|
+
) {
|
|
238
|
+
addSkillBytes(state, candidateStats.size);
|
|
239
|
+
candidates.push(candidate);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return candidates;
|
|
243
|
+
} finally {
|
|
244
|
+
state.ancestors.delete(canonicalDirectory);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function addSkillBytes(state: SkillScanState, bytes: number): void {
|
|
249
|
+
state.skillBytes += bytes;
|
|
250
|
+
if (state.skillBytes > MAX_SKILL_SCAN_BYTES) throwSkillScanLimit();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isSkillScanLimitError(error: unknown): boolean {
|
|
254
|
+
return error instanceof Error && error.name === "SkillScanLimitError";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function throwSkillScanLimit(): never {
|
|
258
|
+
const error = new Error(
|
|
259
|
+
`Subagent skill attachment exceeds traversal limits (${MAX_SKILL_SCAN_ENTRIES} entries, depth ${MAX_SKILL_SCAN_DEPTH}, ${MAX_SKILL_SCAN_BYTES} skill bytes, or ${MAX_SKILL_IGNORE_BYTES} ignore bytes).`,
|
|
260
|
+
);
|
|
261
|
+
error.name = "SkillScanLimitError";
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function throwInvalidDeclaredSkill(): never {
|
|
266
|
+
throw new Error("Subagent skill attachment must not contain an invalid or unreadable declared skill.");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function assertLoadablePackageSkills(
|
|
270
|
+
packageSkillPaths: string[],
|
|
271
|
+
explicitSkillPaths: string[],
|
|
272
|
+
cwd: string,
|
|
273
|
+
scanState: SkillScanState,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
const uniquePackageSkillPaths = deduplicatePackageSkillPaths(packageSkillPaths, explicitSkillPaths);
|
|
276
|
+
for (const [index, skillPath] of uniquePackageSkillPaths.entries()) {
|
|
277
|
+
if (index > 0 && index % 64 === 0) await new Promise<void>((resolve) => setImmediate(resolve));
|
|
278
|
+
throwIfAttachmentAborted(scanState.signal);
|
|
279
|
+
await collectBoundedSkillCandidates(skillPath, scanState);
|
|
280
|
+
}
|
|
281
|
+
for (const [index, skillPath] of uniquePackageSkillPaths.entries()) {
|
|
282
|
+
if (index > 0 && index % 64 === 0) await new Promise<void>((resolve) => setImmediate(resolve));
|
|
283
|
+
throwIfAttachmentAborted(scanState.signal);
|
|
284
|
+
const result = loadExplicitSkills([skillPath], cwd);
|
|
285
|
+
if (result.skills.length === 0) throwInvalidDeclaredSkill();
|
|
286
|
+
assertNoOmittedSkillDiagnostics(result);
|
|
287
|
+
}
|
|
288
|
+
throwIfAttachmentAborted(scanState.signal);
|
|
289
|
+
assertNoSkillNameCollisions(loadExplicitSkills([...uniquePackageSkillPaths, ...explicitSkillPaths], cwd).diagnostics);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function deduplicatePackageSkillPaths(packageSkillPaths: string[], explicitSkillPaths: string[]): string[] {
|
|
293
|
+
const seenPaths = new Set(explicitSkillPaths.map((skillPath) => realpath(skillPath, "Loaded subagent skill")));
|
|
294
|
+
const uniquePaths: string[] = [];
|
|
295
|
+
for (const skillPath of packageSkillPaths) {
|
|
296
|
+
const canonicalPath = realpath(skillPath, "Loaded subagent package skill");
|
|
297
|
+
if (seenPaths.has(canonicalPath)) continue;
|
|
298
|
+
seenPaths.add(canonicalPath);
|
|
299
|
+
uniquePaths.push(skillPath);
|
|
300
|
+
}
|
|
301
|
+
return uniquePaths;
|
|
302
|
+
}
|
package/src/timeout.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
2
|
+
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
|
|
3
|
+
|
|
4
|
+
export function resolveTimeoutMs(timeout: number | undefined): number | undefined {
|
|
5
|
+
if (timeout === undefined) return undefined;
|
|
6
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
7
|
+
throw new Error("Invalid timeout: must be a finite number of seconds");
|
|
8
|
+
}
|
|
9
|
+
const timeoutMs = timeout * 1000;
|
|
10
|
+
if (timeoutMs > MAX_TIMEOUT_MS) {
|
|
11
|
+
throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
|
|
12
|
+
}
|
|
13
|
+
return timeoutMs;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function prepareTimeoutArguments(args: unknown): Record<string, unknown> {
|
|
17
|
+
if (!args || typeof args !== "object") return args as Record<string, unknown>;
|
|
18
|
+
if (!Object.hasOwn(args, "timeoutMs")) return args as Record<string, unknown>;
|
|
19
|
+
const record = args as Record<string, unknown>;
|
|
20
|
+
if (typeof record.timeoutMs !== "number") return record;
|
|
21
|
+
const { timeoutMs, ...prepared } = record;
|
|
22
|
+
if (prepared.timeout === undefined) return { ...prepared, timeout: timeoutMs / 1000 };
|
|
23
|
+
return prepared;
|
|
24
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { type Static, Type } from "typebox";
|
|
4
|
+
import { assertChildBootstrapCapacity } from "./broker-credentials.js";
|
|
5
|
+
import { CHILD_COMMUNICATION_TOOL_NAMES } from "./child-communication-tools.js";
|
|
4
6
|
import {
|
|
5
7
|
type BrokerInboundMessage,
|
|
6
8
|
MAX_IDENTIFIER_LENGTH,
|
|
@@ -10,8 +12,17 @@ import {
|
|
|
10
12
|
validateMessage,
|
|
11
13
|
} from "./message-broker.js";
|
|
12
14
|
import { modelVisibleJson, requireBoundedModelText } from "./model-output.js";
|
|
13
|
-
import {
|
|
15
|
+
import { assertChildCommandCapacity } from "./process.js";
|
|
16
|
+
import {
|
|
17
|
+
MAX_ATTACHED_EXTENSIONS,
|
|
18
|
+
MAX_ATTACHED_SKILLS,
|
|
19
|
+
MAX_EXTENSION_TOOL_NAME_LENGTH,
|
|
20
|
+
MAX_RESOURCE_PATH_BYTES,
|
|
21
|
+
MAX_SELECTED_TOOLS,
|
|
22
|
+
resolveResourceAttachments,
|
|
23
|
+
} from "./resource-attachments.js";
|
|
14
24
|
import { type RuntimeDependencies, SubagentRuntime } from "./runtime.js";
|
|
25
|
+
import { prepareTimeoutArguments, resolveTimeoutMs } from "./timeout.js";
|
|
15
26
|
import {
|
|
16
27
|
CHILD_CORE_TOOL_NAMES,
|
|
17
28
|
DEFAULT_SUBAGENT_TOOLS,
|
|
@@ -20,7 +31,7 @@ import {
|
|
|
20
31
|
} from "./types.js";
|
|
21
32
|
|
|
22
33
|
const MAX_TASK_BYTES = 50 * 1024;
|
|
23
|
-
const MAX_TOOLS =
|
|
34
|
+
const MAX_TOOLS = MAX_SELECTED_TOOLS;
|
|
24
35
|
const MESSAGE_TYPE = "pi-subagents-message";
|
|
25
36
|
const CHILD_CORE_TOOL_SET = new Set<string>(CHILD_CORE_TOOL_NAMES);
|
|
26
37
|
const THINKING_LEVEL_SET = new Set<string>(SUBAGENT_THINKING_LEVELS);
|
|
@@ -42,6 +53,50 @@ const SpawnParameters = Type.Object(
|
|
|
42
53
|
},
|
|
43
54
|
),
|
|
44
55
|
),
|
|
56
|
+
skills: Type.Optional(
|
|
57
|
+
Type.Array(
|
|
58
|
+
Type.String({
|
|
59
|
+
description:
|
|
60
|
+
"Local Markdown skill file or directory whose declared Pi skills all load and have names unique across attachments, resolved from the child working directory.",
|
|
61
|
+
minLength: 1,
|
|
62
|
+
maxLength: MAX_RESOURCE_PATH_BYTES,
|
|
63
|
+
}),
|
|
64
|
+
{
|
|
65
|
+
description: "Explicit local skills available to the child. Automatic skill discovery stays disabled.",
|
|
66
|
+
maxItems: MAX_ATTACHED_SKILLS,
|
|
67
|
+
},
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
extensions: Type.Optional(
|
|
71
|
+
Type.Array(
|
|
72
|
+
Type.Object(
|
|
73
|
+
{
|
|
74
|
+
path: Type.String({
|
|
75
|
+
description: "Local extension file or directory path, resolved from the child working directory.",
|
|
76
|
+
minLength: 1,
|
|
77
|
+
maxLength: MAX_RESOURCE_PATH_BYTES,
|
|
78
|
+
}),
|
|
79
|
+
tools: Type.Array(
|
|
80
|
+
Type.String({
|
|
81
|
+
description: "Exact extension-specific tool name to activate initially; built-in names are rejected.",
|
|
82
|
+
minLength: 1,
|
|
83
|
+
maxLength: MAX_EXTENSION_TOOL_NAME_LENGTH,
|
|
84
|
+
}),
|
|
85
|
+
{
|
|
86
|
+
description:
|
|
87
|
+
"Extension tools to activate. Use an empty list for lifecycle or provider behavior only. All child tool names share a 16 KiB startup bootstrap.",
|
|
88
|
+
maxItems: MAX_SELECTED_TOOLS,
|
|
89
|
+
},
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
{ additionalProperties: false },
|
|
93
|
+
),
|
|
94
|
+
{
|
|
95
|
+
description: "Explicit trusted local extensions loaded in the isolated child process.",
|
|
96
|
+
maxItems: MAX_ATTACHED_EXTENSIONS,
|
|
97
|
+
},
|
|
98
|
+
),
|
|
99
|
+
),
|
|
45
100
|
thinkingLevel: Type.Optional(
|
|
46
101
|
StringEnum(SUBAGENT_THINKING_LEVELS, {
|
|
47
102
|
description: "Child thinking level. Defaults to the main agent's effective level.",
|
|
@@ -109,6 +164,7 @@ type MainSendSelection =
|
|
|
109
164
|
|
|
110
165
|
export interface SubagentToolsDependencies extends RuntimeDependencies {
|
|
111
166
|
createBroker?: (onMessage: (message: BrokerInboundMessage) => void) => MessageBroker;
|
|
167
|
+
platform?: NodeJS.Platform;
|
|
112
168
|
}
|
|
113
169
|
|
|
114
170
|
export interface RegisteredSubagentTools {
|
|
@@ -130,7 +186,7 @@ export function registerSubagentTools(
|
|
|
130
186
|
name: "subagent_spawn",
|
|
131
187
|
label: "Subagent · Spawn",
|
|
132
188
|
description:
|
|
133
|
-
"Use subagent_spawn to start one Pi subagent job and return its jobId immediately. The task defines the child's specialization
|
|
189
|
+
"Use subagent_spawn to start one Pi subagent job and return its jobId immediately. The task defines the child's specialization; selected core tools and explicit trusted local skills or extensions define its initial capabilities. The job may ask the main agent questions and publishes one asynchronous completion when terminal.",
|
|
134
190
|
promptSnippet: "Use subagent_spawn to start one Pi subagent job",
|
|
135
191
|
parameters: SpawnParameters,
|
|
136
192
|
prepareArguments: prepareSpawnArguments,
|
|
@@ -139,18 +195,50 @@ export function registerSubagentTools(
|
|
|
139
195
|
assertNotNested();
|
|
140
196
|
const task = validateTask(params.task, "subagent_spawn");
|
|
141
197
|
const tools = resolveTools(params.tools);
|
|
142
|
-
const
|
|
198
|
+
const cwd = ctx.cwd;
|
|
199
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
200
|
+
const sessionGeneration = runtime.getSessionGeneration();
|
|
201
|
+
const attachments = await resolveResourceAttachments(
|
|
202
|
+
{ skills: params.skills, extensions: params.extensions },
|
|
203
|
+
{ cwd, projectTrusted, coreTools: tools, signal },
|
|
204
|
+
);
|
|
205
|
+
throwIfAborted(signal, "Subagent spawn was cancelled");
|
|
206
|
+
if (runtime.getSessionGeneration() !== sessionGeneration) {
|
|
207
|
+
throw new Error("Subagent spawn session changed during attachment validation; retry the request.");
|
|
208
|
+
}
|
|
209
|
+
if (ctx.cwd !== cwd || ctx.isProjectTrusted() !== projectTrusted) {
|
|
210
|
+
throw new Error("Subagent spawn context changed during attachment validation; retry the request.");
|
|
211
|
+
}
|
|
212
|
+
if (attachments.extensions.length > 0) {
|
|
213
|
+
assertChildBootstrapCapacity([...new Set([...attachments.effectiveTools, ...CHILD_COMMUNICATION_TOOL_NAMES])]);
|
|
214
|
+
}
|
|
215
|
+
const model = resolveChildModel(ctx, attachments.extensions.length > 0);
|
|
143
216
|
const thinkingLevel = resolveThinkingLevel(params.thinkingLevel ?? ctx.thinkingLevel ?? pi.getThinkingLevel());
|
|
144
217
|
resolveTimeoutMs(params.timeout);
|
|
218
|
+
assertChildCommandCapacity(
|
|
219
|
+
{
|
|
220
|
+
tools,
|
|
221
|
+
skills: attachments.skills,
|
|
222
|
+
extensions: attachments.extensions,
|
|
223
|
+
model,
|
|
224
|
+
thinkingLevel,
|
|
225
|
+
projectTrusted,
|
|
226
|
+
},
|
|
227
|
+
dependencies.platform,
|
|
228
|
+
);
|
|
229
|
+
throwIfAborted(signal, "Subagent spawn was cancelled");
|
|
145
230
|
return toolResult(
|
|
146
231
|
runtime.start({
|
|
147
232
|
task,
|
|
148
233
|
tools,
|
|
234
|
+
skills: attachments.skills,
|
|
235
|
+
extensions: attachments.extensions,
|
|
236
|
+
toolSources: attachments.toolSources,
|
|
149
237
|
model,
|
|
150
238
|
thinkingLevel,
|
|
151
|
-
cwd
|
|
239
|
+
cwd,
|
|
152
240
|
timeout: params.timeout,
|
|
153
|
-
projectTrusted
|
|
241
|
+
projectTrusted,
|
|
154
242
|
}),
|
|
155
243
|
);
|
|
156
244
|
},
|
|
@@ -300,11 +388,11 @@ function resolveTools(value: unknown): string[] {
|
|
|
300
388
|
return tools;
|
|
301
389
|
}
|
|
302
390
|
|
|
303
|
-
function resolveChildModel(ctx: ExtensionContext): string {
|
|
391
|
+
function resolveChildModel(ctx: ExtensionContext, hasAttachedExtensions: boolean): string {
|
|
304
392
|
const model = ctx.model;
|
|
305
393
|
if (!model) throw new Error("Subagent model is unavailable because no main-agent model is selected.");
|
|
306
394
|
const provider = sanitizeTerminalText(model.provider).slice(0, 128);
|
|
307
|
-
if (ctx.modelRegistry.getRegisteredProviderIds().includes(model.provider)) {
|
|
395
|
+
if (!hasAttachedExtensions && ctx.modelRegistry.getRegisteredProviderIds().includes(model.provider)) {
|
|
308
396
|
throw new Error(`Subagent model provider ${provider} is unavailable because children disable parent extensions.`);
|
|
309
397
|
}
|
|
310
398
|
if (ctx.modelRegistry.getProviderAuthStatus(model.provider).source === "runtime") {
|
|
@@ -330,16 +418,6 @@ function prepareWaitArguments(args: unknown): WaitArguments {
|
|
|
330
418
|
return prepareTimeoutArguments(args) as WaitArguments;
|
|
331
419
|
}
|
|
332
420
|
|
|
333
|
-
function prepareTimeoutArguments(args: unknown): Record<string, unknown> {
|
|
334
|
-
if (!args || typeof args !== "object") return args as Record<string, unknown>;
|
|
335
|
-
if (!Object.hasOwn(args, "timeoutMs")) return args as Record<string, unknown>;
|
|
336
|
-
const record = args as Record<string, unknown>;
|
|
337
|
-
if (typeof record.timeoutMs !== "number") return record;
|
|
338
|
-
const { timeoutMs, ...prepared } = record;
|
|
339
|
-
if (prepared.timeout === undefined) return { ...prepared, timeout: timeoutMs / 1000 };
|
|
340
|
-
return prepared;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
421
|
function resolveMainSendArguments(params: SendArguments): MainSendSelection {
|
|
344
422
|
validateMessage(params.message, "Subagent message");
|
|
345
423
|
const recipient = optionalIdentifier(params.recipient, "recipient");
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAttachment } from "./resource-attachments.js";
|
|
2
3
|
|
|
3
4
|
export const JOB_STATES = ["queued", "running", "completed", "partial", "failed", "timed_out", "cancelled"] as const;
|
|
4
5
|
|
|
@@ -46,6 +47,9 @@ export interface ChildControl {
|
|
|
46
47
|
export interface ChildRequest {
|
|
47
48
|
task: string;
|
|
48
49
|
tools: string[];
|
|
50
|
+
skills: string[];
|
|
51
|
+
extensions: ExtensionAttachment[];
|
|
52
|
+
toolSources: Record<string, string[]>;
|
|
49
53
|
model: string;
|
|
50
54
|
thinkingLevel: SubagentThinkingLevel;
|
|
51
55
|
cwd: string;
|