@jwilger/pi-development-system 0.10.6 → 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/README.md +9 -1
- package/agents/advisor.md +40 -0
- package/agents/architect.md +66 -0
- package/agents/coder.md +57 -0
- package/agents/implementer.md +47 -0
- package/agents/lens-cagan.md +54 -0
- package/agents/lens-perri.md +54 -0
- package/agents/lens-pichler.md +54 -0
- package/agents/lens-rumelt.md +54 -0
- package/agents/lens-torres.md +54 -0
- package/agents/researcher.md +70 -0
- package/agents/reviewer.md +57 -0
- package/agents/tasker.md +58 -0
- package/agents/writer.md +39 -0
- package/extensions/development-system.ts +4 -0
- package/package.json +5 -3
- package/skills/delegation/SKILL.md +66 -0
- package/src/core/models.ts +18 -0
- package/src/core/routing.ts +67 -0
- package/src/core/spawn-overrides.ts +59 -0
- package/src/jev/questions/route.ts +93 -0
- package/src/state/config.ts +2 -4
- package/src/state/route-task-tool.ts +74 -0
- package/src/subagents/VENDORED.md +40 -0
- package/src/subagents/index.ts +544 -0
- package/src/subagents/orch/inherited-tools.ts +85 -0
- package/src/subagents/orch/mailbox.ts +262 -0
- package/src/subagents/orch/manager.ts +778 -0
- package/src/subagents/orch/paths.ts +74 -0
- package/src/subagents/orch/prompt.ts +25 -0
- package/src/subagents/orch/runtime.ts +791 -0
- package/src/subagents/orch/tools.ts +177 -0
- package/src/subagents/orch/transcript.ts +187 -0
- package/src/subagents/prefs/agent-import.ts +246 -0
- package/src/subagents/prefs/config.ts +1029 -0
- package/src/subagents/prefs/import-discovery.ts +644 -0
- package/src/subagents/prefs/import-instructions.ts +157 -0
- package/src/subagents/prefs/models.ts +85 -0
- package/src/subagents/prefs/settings.ts +371 -0
- package/src/subagents/types.ts +209 -0
- package/src/subagents/ui/agent-navigation-editor.ts +210 -0
- package/src/subagents/ui/dialog.ts +651 -0
- package/src/subagents/ui/import-picker.ts +232 -0
- package/src/subagents/ui/live-agent-view.ts +572 -0
- package/src/subagents/ui/model-picker.ts +452 -0
- package/src/subagents/ui/settings-ui.ts +329 -0
- package/src/subagents/ui/status-ui.ts +545 -0
- package/src/subagents/ui/thread-tree.ts +135 -0
- package/src/subagents/ui/ui.ts +1507 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
// @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
4
|
+
import { clampThinkingLevel } from "@earendil-works/pi-ai/compat";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { mkdir, readFile, realpath } from "node:fs/promises";
|
|
7
|
+
import {
|
|
8
|
+
createAgentSession,
|
|
9
|
+
createCodemodeExtension,
|
|
10
|
+
createToolSearchExtension,
|
|
11
|
+
DefaultResourceLoader,
|
|
12
|
+
getAgentDir,
|
|
13
|
+
ModelRuntime,
|
|
14
|
+
SessionManager,
|
|
15
|
+
SettingsManager,
|
|
16
|
+
type ExtensionContext,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import {
|
|
19
|
+
BOOTSTRAP_MESSAGE,
|
|
20
|
+
buildBootstrapMessage,
|
|
21
|
+
buildQueuedUserMessage,
|
|
22
|
+
buildUpdateDetails,
|
|
23
|
+
DurableMailbox,
|
|
24
|
+
} from "./mailbox.ts";
|
|
25
|
+
import { selectTools } from "../prefs/config.ts";
|
|
26
|
+
import { TranscriptChannel } from "./transcript.ts";
|
|
27
|
+
import type { InheritedToolSource } from "./inherited-tools.ts";
|
|
28
|
+
import type { ModelSelectionMode, ToolFilteringMode } from "../prefs/settings.ts";
|
|
29
|
+
import { applySpawnOverrides } from "../../core/spawn-overrides.ts";
|
|
30
|
+
import {
|
|
31
|
+
modelIdentity,
|
|
32
|
+
getModelPreferences,
|
|
33
|
+
selectPreferredModel,
|
|
34
|
+
ModelPreferenceError,
|
|
35
|
+
} from "../prefs/models.ts";
|
|
36
|
+
import {
|
|
37
|
+
THINKING_LEVELS,
|
|
38
|
+
type AgentType,
|
|
39
|
+
type ResolvedAgentSettings,
|
|
40
|
+
type DriverFactory,
|
|
41
|
+
type DriverOptions,
|
|
42
|
+
type ThinkingLevel,
|
|
43
|
+
} from "../types.ts";
|
|
44
|
+
|
|
45
|
+
const BUILTINS = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"];
|
|
46
|
+
/** Persisted before transcript messages so older-leaf restores still carry ownership. */
|
|
47
|
+
const THREAD_OWNERSHIP_TYPE = "pi-subagent:thread:v1";
|
|
48
|
+
const SESSION_FILE_ERROR =
|
|
49
|
+
"Subagent sessionFile must be a JSONL file inside this root's session directory";
|
|
50
|
+
type ScopedModel = ExtensionContext["scopedModels"][number];
|
|
51
|
+
interface ThreadOwnership {
|
|
52
|
+
rootId: string;
|
|
53
|
+
threadPath: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseModelIdentity(identity: string): { provider: string; id: string } {
|
|
57
|
+
const slash = identity.indexOf("/");
|
|
58
|
+
return { provider: identity.slice(0, slash), id: identity.slice(slash + 1) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function scopedModelsKey(scopedModels: readonly ScopedModel[]): string {
|
|
62
|
+
return scopedModels
|
|
63
|
+
.map(({ model, thinkingLevel }) => `${modelIdentity(model)}\0${thinkingLevel ?? ""}`)
|
|
64
|
+
.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isJsonlWithin(directory: string, file: string): boolean {
|
|
68
|
+
const relative = path.relative(directory, file);
|
|
69
|
+
return (
|
|
70
|
+
!!relative &&
|
|
71
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
72
|
+
relative !== ".." &&
|
|
73
|
+
!path.isAbsolute(relative) &&
|
|
74
|
+
file.endsWith(".jsonl")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function tryRealpath(target: string): Promise<string | undefined> {
|
|
79
|
+
try {
|
|
80
|
+
return await realpath(target);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
83
|
+
if (code === "ENOENT" || code === "ENOTDIR") return undefined;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ownershipFromTranscript(content: string): ThreadOwnership | undefined {
|
|
89
|
+
for (const line of content.split("\n")) {
|
|
90
|
+
if (!line.trim()) continue;
|
|
91
|
+
let parsed: unknown;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(line);
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!parsed || typeof parsed !== "object") continue;
|
|
98
|
+
const entry = parsed as { type?: unknown; customType?: unknown; data?: unknown };
|
|
99
|
+
if (entry.type !== "custom" || entry.customType !== THREAD_OWNERSHIP_TYPE) continue;
|
|
100
|
+
const data = entry.data;
|
|
101
|
+
if (!data || typeof data !== "object") return undefined;
|
|
102
|
+
const { rootId, threadPath } = data as { rootId?: unknown; threadPath?: unknown };
|
|
103
|
+
if (typeof rootId !== "string" || typeof threadPath !== "string") return undefined;
|
|
104
|
+
return { rootId, threadPath };
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Orchestrator file for /root and independent roots; nested parents supply their own file. */
|
|
110
|
+
function resolveParentSession(
|
|
111
|
+
options: DriverOptions,
|
|
112
|
+
rootSessionFile: string | undefined,
|
|
113
|
+
): string | undefined {
|
|
114
|
+
if (options.parentSessionFile) return options.parentSessionFile;
|
|
115
|
+
if (!options.parentPath || options.parentPath === "/root") return rootSessionFile;
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function assertAcceptedSessionFile(
|
|
120
|
+
sessionFile: string,
|
|
121
|
+
sessionDir: string,
|
|
122
|
+
legacyDir: string,
|
|
123
|
+
rootId: string,
|
|
124
|
+
threadPath: string,
|
|
125
|
+
signal: AbortSignal,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
signal.throwIfAborted();
|
|
128
|
+
const sharedDir = await realpath(sessionDir);
|
|
129
|
+
signal.throwIfAborted();
|
|
130
|
+
const file = await tryRealpath(sessionFile);
|
|
131
|
+
signal.throwIfAborted();
|
|
132
|
+
const legacyDirReal = await tryRealpath(legacyDir);
|
|
133
|
+
signal.throwIfAborted();
|
|
134
|
+
// Legacy root-scoped transcripts stay valid, including files nested under that directory.
|
|
135
|
+
if (file && legacyDirReal && isJsonlWithin(legacyDirReal, file)) return;
|
|
136
|
+
if (!file || path.dirname(file) !== sharedDir || !file.endsWith(".jsonl")) {
|
|
137
|
+
throw new Error(SESSION_FILE_ERROR);
|
|
138
|
+
}
|
|
139
|
+
const ownership = ownershipFromTranscript(await readFile(file, { encoding: "utf8", signal }));
|
|
140
|
+
signal.throwIfAborted();
|
|
141
|
+
if (!ownership || ownership.rootId !== rootId || ownership.threadPath !== threadPath) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
"Subagent sessionFile ownership metadata does not match this rootId and thread path",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Isolated SDK sessions with filtered root tool bridges, not reloaded root extensions. */
|
|
149
|
+
export function createDriverFactory(
|
|
150
|
+
getRootContext: () => ExtensionContext,
|
|
151
|
+
getModelSelection: () => ModelSelectionMode = () => "pick-first-scoped",
|
|
152
|
+
getToolFiltering: () => ToolFilteringMode = () => "allowed",
|
|
153
|
+
getInheritedTools: () => InheritedToolSource = () => ({ tools: [], activeNames: [] }),
|
|
154
|
+
): DriverFactory & {
|
|
155
|
+
resolveAgentSettings(type: AgentType, parentPath: string): ResolvedAgentSettings;
|
|
156
|
+
} {
|
|
157
|
+
// Keep resolved settings even after disposal: descendants inherit settings, not the caller's history.
|
|
158
|
+
const resolved = new Map<string, { provider: string; id: string; thinking: ThinkingLevel }>();
|
|
159
|
+
const normalizeScopedModels = (
|
|
160
|
+
scopedModels: ExtensionContext["scopedModels"] | null | undefined,
|
|
161
|
+
): readonly ScopedModel[] => scopedModels ?? [];
|
|
162
|
+
const availableModelCandidates = () =>
|
|
163
|
+
getRootContext()
|
|
164
|
+
.modelRegistry.getAvailable()
|
|
165
|
+
.map((model) => ({ model }));
|
|
166
|
+
const availableScopedModels = (scopedModels: readonly ScopedModel[]) => {
|
|
167
|
+
const available = new Set(availableModelCandidates().map(({ model }) => modelIdentity(model)));
|
|
168
|
+
return scopedModels.filter(({ model }) => available.has(modelIdentity(model)));
|
|
169
|
+
};
|
|
170
|
+
const selectTypeModelPreference = (
|
|
171
|
+
type: AgentType,
|
|
172
|
+
candidates: readonly { model: { provider: string; id: string } }[],
|
|
173
|
+
filtering: boolean,
|
|
174
|
+
) => {
|
|
175
|
+
try {
|
|
176
|
+
return selectPreferredModel(type, candidates, filtering);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (
|
|
179
|
+
filtering &&
|
|
180
|
+
candidates.length === 0 &&
|
|
181
|
+
error instanceof Error &&
|
|
182
|
+
!error.message.includes("[]")
|
|
183
|
+
) {
|
|
184
|
+
const message = `${error.message} Current /scoped-models scope: [].`;
|
|
185
|
+
throw error instanceof ModelPreferenceError
|
|
186
|
+
? new ModelPreferenceError(message, error.scopedModelFiltering)
|
|
187
|
+
: new Error(message);
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
// Discovery and new/restored drivers share model selection and thinking inheritance.
|
|
193
|
+
const resolveInitialSettings = (
|
|
194
|
+
type: AgentType,
|
|
195
|
+
parentPath?: string | null,
|
|
196
|
+
restored?: ReturnType<SessionManager["buildSessionContext"]>,
|
|
197
|
+
) => {
|
|
198
|
+
const ctx = getRootContext();
|
|
199
|
+
const parent = parentPath
|
|
200
|
+
? resolved.get(`${ctx.sessionManager.getSessionId()}:${parentPath}`)
|
|
201
|
+
: undefined;
|
|
202
|
+
const mode = getModelSelection();
|
|
203
|
+
const preferences = mode === "use-current" ? undefined : getModelPreferences(type);
|
|
204
|
+
let provider = parent?.provider ?? ctx.model?.provider;
|
|
205
|
+
let id = parent?.id ?? ctx.model?.id;
|
|
206
|
+
if (mode === "use-current") {
|
|
207
|
+
provider = ctx.model?.provider;
|
|
208
|
+
id = ctx.model?.id;
|
|
209
|
+
} else if (preferences !== undefined) {
|
|
210
|
+
const filtering = mode === "pick-first-scoped";
|
|
211
|
+
const candidates = filtering
|
|
212
|
+
? availableScopedModels(normalizeScopedModels(ctx.scopedModels))
|
|
213
|
+
: availableModelCandidates();
|
|
214
|
+
({ provider, id } = parseModelIdentity(
|
|
215
|
+
selectTypeModelPreference(type, candidates, filtering)!,
|
|
216
|
+
));
|
|
217
|
+
} else if (restored?.model) {
|
|
218
|
+
provider = restored.model.provider;
|
|
219
|
+
id = restored.model.modelId;
|
|
220
|
+
}
|
|
221
|
+
const savedThinking =
|
|
222
|
+
restored && THINKING_LEVELS.includes(restored.thinkingLevel as ThinkingLevel)
|
|
223
|
+
? (restored.thinkingLevel as ThinkingLevel)
|
|
224
|
+
: undefined;
|
|
225
|
+
const thinkingLevel =
|
|
226
|
+
savedThinking ?? type.thinkingLevel ?? parent?.thinking ?? ctx.thinkingLevel ?? "off";
|
|
227
|
+
// devsys: a per-spawn pin from agent_spawn wins; restored sessions already persist their choice.
|
|
228
|
+
if (restored === undefined)
|
|
229
|
+
return applySpawnOverrides({ provider, id, thinkingLevel }, type.spawnOverrides);
|
|
230
|
+
return { provider, id, thinkingLevel };
|
|
231
|
+
};
|
|
232
|
+
const resolveAgentSettings = (type: AgentType, parentPath: string): ResolvedAgentSettings => {
|
|
233
|
+
const ctx = getRootContext();
|
|
234
|
+
const { provider, id, thinkingLevel } = resolveInitialSettings(type, parentPath);
|
|
235
|
+
const model =
|
|
236
|
+
normalizeScopedModels(ctx.scopedModels).find(
|
|
237
|
+
({ model }) => model.provider === provider && model.id === id,
|
|
238
|
+
)?.model ?? (provider && id ? ctx.modelRegistry.find(provider, id) : undefined);
|
|
239
|
+
if (!model)
|
|
240
|
+
throw new Error(
|
|
241
|
+
`Subagent model ${provider ?? "(unset)"}/${id ?? "(unset)"} is unavailable; select a physical model in the root or agent type`,
|
|
242
|
+
);
|
|
243
|
+
if (model.api === "pi-virtual")
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Virtual model ${provider}/${id} cannot be reproduced through the public registry API. Set this agent type's model to a physical provider/model-id.`,
|
|
246
|
+
);
|
|
247
|
+
return { model: modelIdentity(model), thinkingLevel: clampThinkingLevel(model, thinkingLevel) };
|
|
248
|
+
};
|
|
249
|
+
const createDriver: DriverFactory = async (options) => {
|
|
250
|
+
options.signal.throwIfAborted();
|
|
251
|
+
const ctx = getRootContext();
|
|
252
|
+
// Tool policy is a startup snapshot; retained live sessions keep their selected set.
|
|
253
|
+
const toolFiltering = getToolFiltering();
|
|
254
|
+
const rootId = ctx.sessionManager.getSessionId();
|
|
255
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(rootId))
|
|
256
|
+
throw new Error("Unsafe root session id for subagent storage");
|
|
257
|
+
const agentDir = getAgentDir();
|
|
258
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
259
|
+
const legacyDir = path.join(agentDir, "subagents", rootId);
|
|
260
|
+
options.signal.throwIfAborted();
|
|
261
|
+
await mkdir(sessionDir, { recursive: true });
|
|
262
|
+
options.signal.throwIfAborted();
|
|
263
|
+
let sessionManager: SessionManager;
|
|
264
|
+
if (options.sessionFile) {
|
|
265
|
+
await assertAcceptedSessionFile(
|
|
266
|
+
options.sessionFile,
|
|
267
|
+
sessionDir,
|
|
268
|
+
legacyDir,
|
|
269
|
+
rootId,
|
|
270
|
+
options.path,
|
|
271
|
+
options.signal,
|
|
272
|
+
);
|
|
273
|
+
// Open the caller path, not its realpath, so retained sessionFile identity is stable.
|
|
274
|
+
sessionManager = SessionManager.open(path.resolve(options.sessionFile), sessionDir, ctx.cwd);
|
|
275
|
+
if (options.sessionLeafId !== undefined) {
|
|
276
|
+
if (options.sessionLeafId === null) sessionManager.resetLeaf();
|
|
277
|
+
else sessionManager.branch(options.sessionLeafId);
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
const parentSession = resolveParentSession(options, ctx.sessionManager.getSessionFile());
|
|
281
|
+
sessionManager = SessionManager.create(
|
|
282
|
+
ctx.cwd,
|
|
283
|
+
sessionDir,
|
|
284
|
+
parentSession ? { parentSession } : undefined,
|
|
285
|
+
);
|
|
286
|
+
// Before transcript messages so an older restored leaf still has name and ownership as ancestors.
|
|
287
|
+
sessionManager.appendSessionInfo(`${options.type.name} ${options.path}`);
|
|
288
|
+
sessionManager.appendCustomEntry(THREAD_OWNERSHIP_TYPE, {
|
|
289
|
+
rootId,
|
|
290
|
+
threadPath: options.path,
|
|
291
|
+
inheritedCount: options.inherited.length,
|
|
292
|
+
});
|
|
293
|
+
for (const message of options.inherited) {
|
|
294
|
+
// Summary messages are projections, not appendable SDK session entries.
|
|
295
|
+
if (message.role === "compactionSummary" || message.role === "branchSummary") {
|
|
296
|
+
sessionManager.appendMessage({
|
|
297
|
+
role: "user",
|
|
298
|
+
content: message.summary,
|
|
299
|
+
timestamp: message.timestamp,
|
|
300
|
+
});
|
|
301
|
+
} else if (message.role === "custom") {
|
|
302
|
+
sessionManager.appendCustomMessageEntry(
|
|
303
|
+
message.customType,
|
|
304
|
+
message.content,
|
|
305
|
+
message.display,
|
|
306
|
+
message.details,
|
|
307
|
+
);
|
|
308
|
+
} else if (
|
|
309
|
+
message.role === "user" ||
|
|
310
|
+
message.role === "assistant" ||
|
|
311
|
+
message.role === "toolResult" ||
|
|
312
|
+
message.role === "bashExecution"
|
|
313
|
+
) {
|
|
314
|
+
sessionManager.appendMessage(message);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const restored = options.sessionFile ? sessionManager.buildSessionContext() : undefined;
|
|
319
|
+
const modelSelection = getModelSelection();
|
|
320
|
+
// Use Current ignores definition preferences, including for restored/nested sessions.
|
|
321
|
+
// devsys: an explicit per-spawn model pin bypasses preference/scope policy.
|
|
322
|
+
const modelPreferences =
|
|
323
|
+
modelSelection === "use-current" || options.type.spawnOverrides?.model !== undefined
|
|
324
|
+
? undefined
|
|
325
|
+
: getModelPreferences(options.type);
|
|
326
|
+
const filteringEnabled = () => getModelSelection() === "pick-first-scoped";
|
|
327
|
+
const selectModelPreference = (
|
|
328
|
+
candidates: readonly { model: { provider: string; id: string } }[],
|
|
329
|
+
filtering = filteringEnabled(),
|
|
330
|
+
) => selectTypeModelPreference(options.type, candidates, filtering);
|
|
331
|
+
const initialScopedModels = normalizeScopedModels(ctx.scopedModels);
|
|
332
|
+
const { provider, id, thinkingLevel } = resolveInitialSettings(
|
|
333
|
+
options.type,
|
|
334
|
+
options.parentPath,
|
|
335
|
+
restored,
|
|
336
|
+
);
|
|
337
|
+
options.signal.throwIfAborted();
|
|
338
|
+
const runtime = await ModelRuntime.create({
|
|
339
|
+
authPath: path.join(agentDir, "auth.json"),
|
|
340
|
+
modelsPath: path.join(agentDir, "models.json"),
|
|
341
|
+
modelsStorePath: path.join(agentDir, "models-store.json"),
|
|
342
|
+
allowModelNetwork: false,
|
|
343
|
+
signal: options.signal,
|
|
344
|
+
});
|
|
345
|
+
options.signal.throwIfAborted();
|
|
346
|
+
for (const providerId of ctx.modelRegistry.getRegisteredProviderIds()) {
|
|
347
|
+
const native = ctx.modelRegistry.getRegisteredNativeProvider(providerId);
|
|
348
|
+
const config = ctx.modelRegistry.getRegisteredProviderConfig(providerId);
|
|
349
|
+
if (native) runtime.registerNativeProvider(native);
|
|
350
|
+
if (config) runtime.registerProvider(providerId, config);
|
|
351
|
+
}
|
|
352
|
+
const mirrorRuntimeAuth = async (providerId: string) => {
|
|
353
|
+
options.signal.throwIfAborted();
|
|
354
|
+
if (ctx.modelRegistry.getProviderAuthStatus(providerId).source !== "runtime") return;
|
|
355
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(providerId);
|
|
356
|
+
options.signal.throwIfAborted();
|
|
357
|
+
if (apiKey !== undefined)
|
|
358
|
+
await runtime.setRuntimeApiKey(providerId, apiKey, { signal: options.signal });
|
|
359
|
+
options.signal.throwIfAborted();
|
|
360
|
+
};
|
|
361
|
+
const resolveRuntimeModel = async (
|
|
362
|
+
providerId: string | undefined,
|
|
363
|
+
modelId: string | undefined,
|
|
364
|
+
scopedModels: readonly ScopedModel[],
|
|
365
|
+
) => {
|
|
366
|
+
if (!providerId || !modelId)
|
|
367
|
+
throw new Error(
|
|
368
|
+
`Subagent model ${providerId ?? "(unset)"}/${modelId ?? "(unset)"} is unavailable; select a physical model in the root or agent type`,
|
|
369
|
+
);
|
|
370
|
+
await mirrorRuntimeAuth(providerId);
|
|
371
|
+
const sourceModel =
|
|
372
|
+
scopedModels.find(({ model }) => model.provider === providerId && model.id === modelId)
|
|
373
|
+
?.model ?? ctx.modelRegistry.find(providerId, modelId);
|
|
374
|
+
if (sourceModel?.api === "pi-virtual") {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Virtual model ${providerId}/${modelId} cannot be reproduced through the public registry API. Set this agent type's model to a physical provider/model-id.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const runtimeModel = runtime.getModel(providerId, modelId);
|
|
380
|
+
if (!runtimeModel)
|
|
381
|
+
throw new Error(
|
|
382
|
+
`Subagent model ${providerId}/${modelId} is unavailable; select a physical model in the root or agent type`,
|
|
383
|
+
);
|
|
384
|
+
return runtimeModel;
|
|
385
|
+
};
|
|
386
|
+
const model = await resolveRuntimeModel(provider, id, initialScopedModels);
|
|
387
|
+
const inherited = getInheritedTools();
|
|
388
|
+
const localNames = new Set([...BUILTINS, ...options.tools.map((tool) => tool.name)]);
|
|
389
|
+
const externalTools = inherited.tools.filter((tool) => !localNames.has(tool.name));
|
|
390
|
+
const toolNames = selectTools(
|
|
391
|
+
options.type.tools,
|
|
392
|
+
[...localNames, ...externalTools.map((tool) => tool.name)],
|
|
393
|
+
toolFiltering,
|
|
394
|
+
);
|
|
395
|
+
const allowed = new Set(toolNames);
|
|
396
|
+
const rootActive = new Set(inherited.activeNames);
|
|
397
|
+
// Preserve deferred/codemode exposure rather than flooding model declarations with MCP tools.
|
|
398
|
+
const activeToolNames = toolNames.filter(
|
|
399
|
+
(name) => localNames.has(name) || rootActive.has(name) || options.type.tools?.allow?.includes(name) && toolFiltering === "allowed",
|
|
400
|
+
);
|
|
401
|
+
const customTools = [...options.tools, ...externalTools].filter(
|
|
402
|
+
(tool) => allowed.has(tool.name) && tool.name !== "codemode" && tool.name !== "tool_search",
|
|
403
|
+
);
|
|
404
|
+
const settingsManager = SettingsManager.inMemory({ cacheWarming: "off" });
|
|
405
|
+
let parkQueue = () => {};
|
|
406
|
+
const pauseBoundary = () => {
|
|
407
|
+
if (!options.shouldPause()) return undefined;
|
|
408
|
+
parkQueue();
|
|
409
|
+
return { continue: false };
|
|
410
|
+
};
|
|
411
|
+
const loader = new DefaultResourceLoader({
|
|
412
|
+
cwd: ctx.cwd,
|
|
413
|
+
agentDir,
|
|
414
|
+
settingsManager,
|
|
415
|
+
noExtensions: true,
|
|
416
|
+
noSkills: true,
|
|
417
|
+
noPromptTemplates: true,
|
|
418
|
+
noContextFiles: true,
|
|
419
|
+
noThemes: true,
|
|
420
|
+
systemPrompt: [
|
|
421
|
+
options.type.systemPrompt,
|
|
422
|
+
`Your thread path is ${options.path}.`,
|
|
423
|
+
options.parentPath
|
|
424
|
+
? `Your lexical parent is ${options.parentPath}; inherited conversation comes only from that parent.`
|
|
425
|
+
: "You are an independent root thread with no inherited history.",
|
|
426
|
+
"When finished, give your final answer normally so it can be handed back. To retain unfinished work without a final answer, call agent_pause (if available), then stop; resume only when instructed.",
|
|
427
|
+
...(allowed.has("agent_spawn") && allowed.has("agent_wait")
|
|
428
|
+
? [
|
|
429
|
+
"Name child paths with concise task-based kebab-case slugs (e.g. /root/controller-security-research), independent of type.",
|
|
430
|
+
"For independent parallel work, launch all siblings with agent_spawn wait:false before calling agent_wait. Children share the main conversation's depth and concurrency limits; waiting parents count as active. Do not delegate work beyond your assigned scope.",
|
|
431
|
+
]
|
|
432
|
+
: []),
|
|
433
|
+
].join("\n\n"),
|
|
434
|
+
appendSystemPromptOverride: () => [],
|
|
435
|
+
extensionFactories: [
|
|
436
|
+
// These orchestrators must see the CHILD loadout. Forwarding root codemode would
|
|
437
|
+
// let scripts discover/call tools outside the child's allow/block policy.
|
|
438
|
+
...(allowed.has("codemode") ? [createCodemodeExtension()] : []),
|
|
439
|
+
...(allowed.has("tool_search") ? [createToolSearchExtension()] : []),
|
|
440
|
+
(pi) => {
|
|
441
|
+
pi.on("turn_end", pauseBoundary);
|
|
442
|
+
pi.on("agent_before_settle", pauseBoundary);
|
|
443
|
+
pi.on("tool_call", (event) =>
|
|
444
|
+
allowed.has(event.toolName)
|
|
445
|
+
? undefined
|
|
446
|
+
: { block: true, reason: "Tool is not allowed by this agent type" },
|
|
447
|
+
);
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
});
|
|
451
|
+
options.signal.throwIfAborted();
|
|
452
|
+
await loader.reload();
|
|
453
|
+
options.signal.throwIfAborted();
|
|
454
|
+
const { session } = await createAgentSession({
|
|
455
|
+
cwd: ctx.cwd,
|
|
456
|
+
agentDir,
|
|
457
|
+
modelRuntime: runtime,
|
|
458
|
+
model,
|
|
459
|
+
thinkingLevel,
|
|
460
|
+
scopedModels: [...initialScopedModels],
|
|
461
|
+
settingsManager,
|
|
462
|
+
resourceLoader: loader,
|
|
463
|
+
sessionManager,
|
|
464
|
+
// SDK `tools` is both a registry allowlist and an initial active list.
|
|
465
|
+
// Register every permitted deferred tool, then narrow declarations after bind.
|
|
466
|
+
tools: toolNames,
|
|
467
|
+
customTools,
|
|
468
|
+
});
|
|
469
|
+
let lastScopedModelsKey = "";
|
|
470
|
+
const updateResolved = () => {
|
|
471
|
+
const currentModel = session.model;
|
|
472
|
+
if (!currentModel) throw new Error(`Subagent ${options.path} has no selected model`);
|
|
473
|
+
resolved.set(`${rootId}:${options.path}`, {
|
|
474
|
+
provider: currentModel.provider,
|
|
475
|
+
id: currentModel.id,
|
|
476
|
+
thinking: session.thinkingLevel,
|
|
477
|
+
});
|
|
478
|
+
};
|
|
479
|
+
const assertScopedRequestAuthorized = (
|
|
480
|
+
currentIdentity: string | undefined,
|
|
481
|
+
liveScopedModels: readonly ScopedModel[],
|
|
482
|
+
) => {
|
|
483
|
+
if (!filteringEnabled() || modelPreferences === undefined) return;
|
|
484
|
+
const preferredIdentity = selectModelPreference(
|
|
485
|
+
availableScopedModels(liveScopedModels),
|
|
486
|
+
true,
|
|
487
|
+
);
|
|
488
|
+
if (!preferredIdentity) return;
|
|
489
|
+
if (!currentIdentity) throw new Error(`Subagent ${options.path} has no selected model`);
|
|
490
|
+
if (currentIdentity !== preferredIdentity) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`Agent type ${JSON.stringify(options.type.name)} scope changed for subagent ${JSON.stringify(options.path)}: current model is ${currentIdentity} but preferred is ${preferredIdentity}; resume agent to use ${preferredIdentity}.`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
const assertCurrentModelAuthorized = () => {
|
|
497
|
+
const currentModel = session.model;
|
|
498
|
+
assertScopedRequestAuthorized(
|
|
499
|
+
currentModel ? modelIdentity(currentModel) : undefined,
|
|
500
|
+
normalizeScopedModels(getRootContext().scopedModels),
|
|
501
|
+
);
|
|
502
|
+
};
|
|
503
|
+
const enforceScopedModelPolicy = async () => {
|
|
504
|
+
const liveScopedModels = normalizeScopedModels(getRootContext().scopedModels);
|
|
505
|
+
const key = scopedModelsKey(liveScopedModels);
|
|
506
|
+
if (key !== lastScopedModelsKey) {
|
|
507
|
+
session.setScopedModels([...liveScopedModels]);
|
|
508
|
+
lastScopedModelsKey = key;
|
|
509
|
+
}
|
|
510
|
+
if (!filteringEnabled() || modelPreferences === undefined) {
|
|
511
|
+
updateResolved();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const preferredIdentity = selectModelPreference(
|
|
515
|
+
availableScopedModels(liveScopedModels),
|
|
516
|
+
true,
|
|
517
|
+
);
|
|
518
|
+
if (!preferredIdentity) {
|
|
519
|
+
updateResolved();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const currentModel = session.model;
|
|
523
|
+
if (!currentModel || modelIdentity(currentModel) !== preferredIdentity) {
|
|
524
|
+
const { provider: preferredProvider, id: preferredId } =
|
|
525
|
+
parseModelIdentity(preferredIdentity);
|
|
526
|
+
await session.setModel(
|
|
527
|
+
await resolveRuntimeModel(preferredProvider, preferredId, liveScopedModels),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
updateResolved();
|
|
531
|
+
};
|
|
532
|
+
try {
|
|
533
|
+
await enforceScopedModelPolicy();
|
|
534
|
+
options.signal.throwIfAborted();
|
|
535
|
+
await session.bindExtensions({ mode: "print" });
|
|
536
|
+
session.setActiveToolsByName(activeToolNames);
|
|
537
|
+
options.signal.throwIfAborted();
|
|
538
|
+
} catch (error) {
|
|
539
|
+
session.dispose();
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
const streamFunction = session.agent.streamFunction;
|
|
543
|
+
session.agent.streamFunction = async (requestModel, context, streamOptions) => {
|
|
544
|
+
assertScopedRequestAuthorized(
|
|
545
|
+
modelIdentity(requestModel),
|
|
546
|
+
normalizeScopedModels(getRootContext().scopedModels),
|
|
547
|
+
);
|
|
548
|
+
return streamFunction(requestModel, context, streamOptions);
|
|
549
|
+
};
|
|
550
|
+
const mailbox = new DurableMailbox(sessionManager);
|
|
551
|
+
const emitCheckpoint = () =>
|
|
552
|
+
options.onEvent({
|
|
553
|
+
kind: "checkpoint",
|
|
554
|
+
text: "",
|
|
555
|
+
sessionFile: session.sessionFile,
|
|
556
|
+
sessionLeafId: sessionManager.getLeafId(),
|
|
557
|
+
});
|
|
558
|
+
const restoreAppends = mailbox.wrapAppends(emitCheckpoint);
|
|
559
|
+
const ensurePersistedSession = () => {
|
|
560
|
+
if (session.sessionFile && existsSync(session.sessionFile)) return;
|
|
561
|
+
sessionManager.appendMessage(buildBootstrapMessage());
|
|
562
|
+
emit("activity", BOOTSTRAP_MESSAGE);
|
|
563
|
+
};
|
|
564
|
+
const queueAccepted = async (kind: "steer" | "followUp", content: string) => {
|
|
565
|
+
assertCurrentModelAuthorized();
|
|
566
|
+
ensurePersistedSession();
|
|
567
|
+
const accepted = mailbox.accept(kind, content);
|
|
568
|
+
emitCheckpoint();
|
|
569
|
+
if (kind === "followUp") session.agent.followUp(buildQueuedUserMessage(accepted));
|
|
570
|
+
else session.agent.steer(buildQueuedUserMessage(accepted));
|
|
571
|
+
mailbox.markEnqueued(accepted.id);
|
|
572
|
+
};
|
|
573
|
+
const resumePending = async () => {
|
|
574
|
+
for (const accepted of mailbox.replayablePending()) {
|
|
575
|
+
if (accepted.kind === "update") {
|
|
576
|
+
await session.sendCustomMessage(
|
|
577
|
+
{
|
|
578
|
+
customType: "subagent-update",
|
|
579
|
+
content: accepted.content,
|
|
580
|
+
display: true,
|
|
581
|
+
details: buildUpdateDetails(accepted),
|
|
582
|
+
},
|
|
583
|
+
{ triggerTurn: false },
|
|
584
|
+
);
|
|
585
|
+
mailbox.markEnqueued(accepted.id);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (accepted.kind === "followUp") session.agent.followUp(buildQueuedUserMessage(accepted));
|
|
589
|
+
else session.agent.steer(buildQueuedUserMessage(accepted));
|
|
590
|
+
mailbox.markEnqueued(accepted.id);
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
const clearPromptQueues = () => {
|
|
594
|
+
session.clearQueue();
|
|
595
|
+
mailbox.clearQueuedInputs();
|
|
596
|
+
};
|
|
597
|
+
// In 0.99.2 a boundary's continue:false only declines EXTRA continuation;
|
|
598
|
+
// the public core hook is needed to stop automatic tool/steering continuation.
|
|
599
|
+
parkQueue = clearPromptQueues;
|
|
600
|
+
const finishTurn = session.agent.finishTurn;
|
|
601
|
+
session.agent.finishTurn = async (turn, signal) => {
|
|
602
|
+
const decision = await finishTurn?.(turn, signal);
|
|
603
|
+
if (options.shouldPause()) {
|
|
604
|
+
clearPromptQueues();
|
|
605
|
+
return { action: "end" };
|
|
606
|
+
}
|
|
607
|
+
return decision || undefined;
|
|
608
|
+
};
|
|
609
|
+
updateResolved();
|
|
610
|
+
let disposed = false;
|
|
611
|
+
let running = false;
|
|
612
|
+
let aborted = false;
|
|
613
|
+
let baseline = session.messages.length;
|
|
614
|
+
// Subscribe after inherited/restored history is loaded; only new assistant events are counted.
|
|
615
|
+
let settledInput = 0;
|
|
616
|
+
let settledOutput = 0;
|
|
617
|
+
const countedUsage = new WeakSet<object>();
|
|
618
|
+
let finalOutput = "";
|
|
619
|
+
let abortPromise: Promise<void> | undefined;
|
|
620
|
+
const emit = (kind: "activity" | "error", text: string) =>
|
|
621
|
+
options.onEvent({ kind, text: text.slice(0, 1000) });
|
|
622
|
+
const assistantUsage = (message: AssistantMessage) => {
|
|
623
|
+
const usage = message.usage;
|
|
624
|
+
const input = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
625
|
+
const output = usage.output;
|
|
626
|
+
if (!Number.isFinite(input) || !Number.isFinite(output)) return;
|
|
627
|
+
return { input, output, reported: input + output + usage.totalTokens > 0 };
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const ownership = sessionManager
|
|
631
|
+
.getBranch()
|
|
632
|
+
.find((entry) => entry.type === "custom" && entry.customType === THREAD_OWNERSHIP_TYPE);
|
|
633
|
+
const inheritedMetadata =
|
|
634
|
+
ownership?.type === "custom"
|
|
635
|
+
? (ownership.data as { inheritedCount?: unknown })?.inheritedCount
|
|
636
|
+
: undefined;
|
|
637
|
+
const inheritedCount =
|
|
638
|
+
typeof inheritedMetadata === "number" &&
|
|
639
|
+
Number.isInteger(inheritedMetadata) &&
|
|
640
|
+
inheritedMetadata >= 0
|
|
641
|
+
? inheritedMetadata
|
|
642
|
+
: options.inherited.length;
|
|
643
|
+
const transcript = new TranscriptChannel(
|
|
644
|
+
() => session.messages,
|
|
645
|
+
// A restored compaction has replaced the original inherited prefix.
|
|
646
|
+
session.messages.some((message) => message.role === "compactionSummary") ? 0 : inheritedCount,
|
|
647
|
+
);
|
|
648
|
+
const unsubscribe = session.subscribe((event) => {
|
|
649
|
+
transcript.accept(event);
|
|
650
|
+
if (event.type === "tool_execution_start") emit("activity", `Tool: ${event.toolName}`);
|
|
651
|
+
if (event.type === "tool_execution_end")
|
|
652
|
+
emit(
|
|
653
|
+
event.isError ? "error" : "activity",
|
|
654
|
+
`Tool ${event.toolName}: ${event.isError ? "failed" : "finished"}`,
|
|
655
|
+
);
|
|
656
|
+
if (
|
|
657
|
+
(event.type === "message_update" || event.type === "message_end") &&
|
|
658
|
+
event.message.role === "assistant" &&
|
|
659
|
+
!countedUsage.has(event.message)
|
|
660
|
+
) {
|
|
661
|
+
const usage = assistantUsage(event.message);
|
|
662
|
+
if (event.type === "message_update") {
|
|
663
|
+
if (usage?.reported)
|
|
664
|
+
options.onEvent({
|
|
665
|
+
kind: "usage",
|
|
666
|
+
inputTokens: settledInput + usage.input,
|
|
667
|
+
outputTokens: settledOutput + usage.output,
|
|
668
|
+
partial: true,
|
|
669
|
+
});
|
|
670
|
+
} else {
|
|
671
|
+
countedUsage.add(event.message);
|
|
672
|
+
if (usage) {
|
|
673
|
+
settledInput += usage.input;
|
|
674
|
+
settledOutput += usage.output;
|
|
675
|
+
}
|
|
676
|
+
options.onEvent({
|
|
677
|
+
kind: "usage",
|
|
678
|
+
inputTokens: settledInput,
|
|
679
|
+
outputTokens: settledOutput,
|
|
680
|
+
});
|
|
681
|
+
const text = event.message.content
|
|
682
|
+
.filter((block) => block.type === "text")
|
|
683
|
+
.map((block) => block.text)
|
|
684
|
+
.join("");
|
|
685
|
+
if (text) emit("activity", text);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
const assertOpen = () => {
|
|
690
|
+
if (disposed) throw new Error("Subagent driver is disposed");
|
|
691
|
+
};
|
|
692
|
+
return {
|
|
693
|
+
get sessionFile() {
|
|
694
|
+
const file = session.sessionFile;
|
|
695
|
+
return file && existsSync(file) ? file : undefined;
|
|
696
|
+
},
|
|
697
|
+
get sessionLeafId() {
|
|
698
|
+
return sessionManager.getLeafId();
|
|
699
|
+
},
|
|
700
|
+
async prompt(message) {
|
|
701
|
+
assertOpen();
|
|
702
|
+
if (running) throw new Error("Subagent is already running; use steer instead");
|
|
703
|
+
running = true;
|
|
704
|
+
aborted = false;
|
|
705
|
+
baseline = session.messages.length;
|
|
706
|
+
finalOutput = "";
|
|
707
|
+
try {
|
|
708
|
+
await enforceScopedModelPolicy();
|
|
709
|
+
await resumePending();
|
|
710
|
+
await session.prompt(message, { expandPromptTemplates: false });
|
|
711
|
+
await session.waitForIdle();
|
|
712
|
+
const last = session.messages
|
|
713
|
+
.slice(baseline)
|
|
714
|
+
.filter((msg) => msg.role === "assistant")
|
|
715
|
+
.at(-1);
|
|
716
|
+
if (aborted || last?.stopReason === "aborted") throw new Error("Subagent run aborted");
|
|
717
|
+
if (last?.stopReason === "error")
|
|
718
|
+
throw new Error(last.errorMessage || "Subagent model failed");
|
|
719
|
+
if (last && !options.shouldPause())
|
|
720
|
+
finalOutput = last.content
|
|
721
|
+
.filter((block) => block.type === "text")
|
|
722
|
+
.map((block) => block.text)
|
|
723
|
+
.join("");
|
|
724
|
+
} catch (error) {
|
|
725
|
+
emit("error", error instanceof Error ? error.message : String(error));
|
|
726
|
+
throw error;
|
|
727
|
+
} finally {
|
|
728
|
+
updateResolved();
|
|
729
|
+
running = false;
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
async steer(message) {
|
|
733
|
+
assertOpen();
|
|
734
|
+
await queueAccepted("steer", message);
|
|
735
|
+
},
|
|
736
|
+
snapshot() {
|
|
737
|
+
return structuredClone(session.messages);
|
|
738
|
+
},
|
|
739
|
+
observeTranscript(listener) {
|
|
740
|
+
assertOpen();
|
|
741
|
+
return transcript.observe(listener);
|
|
742
|
+
},
|
|
743
|
+
output() {
|
|
744
|
+
return finalOutput;
|
|
745
|
+
},
|
|
746
|
+
async abort() {
|
|
747
|
+
if (disposed) return;
|
|
748
|
+
if (running) aborted = true;
|
|
749
|
+
clearPromptQueues();
|
|
750
|
+
if (!abortPromise)
|
|
751
|
+
abortPromise = session.abort().finally(() => {
|
|
752
|
+
abortPromise = undefined;
|
|
753
|
+
});
|
|
754
|
+
await abortPromise;
|
|
755
|
+
},
|
|
756
|
+
dispose() {
|
|
757
|
+
if (disposed) return;
|
|
758
|
+
disposed = true;
|
|
759
|
+
aborted = true;
|
|
760
|
+
restoreAppends();
|
|
761
|
+
transcript.dispose();
|
|
762
|
+
unsubscribe();
|
|
763
|
+
session.dispose();
|
|
764
|
+
},
|
|
765
|
+
async sendUpdate(content) {
|
|
766
|
+
assertOpen();
|
|
767
|
+
try {
|
|
768
|
+
ensurePersistedSession();
|
|
769
|
+
const accepted = mailbox.accept("update", content);
|
|
770
|
+
emitCheckpoint();
|
|
771
|
+
// SDK session method is sendCustomMessage (ExtensionAPI calls it sendMessage).
|
|
772
|
+
await session.sendCustomMessage(
|
|
773
|
+
{
|
|
774
|
+
customType: "subagent-update",
|
|
775
|
+
content,
|
|
776
|
+
display: true,
|
|
777
|
+
details: buildUpdateDetails(accepted),
|
|
778
|
+
},
|
|
779
|
+
{ triggerTurn: false },
|
|
780
|
+
);
|
|
781
|
+
mailbox.markEnqueued(accepted.id);
|
|
782
|
+
transcript.reconcile();
|
|
783
|
+
} catch (error) {
|
|
784
|
+
emit("error", error instanceof Error ? error.message : String(error));
|
|
785
|
+
throw error;
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
};
|
|
790
|
+
return Object.assign(createDriver, { resolveAgentSettings });
|
|
791
|
+
}
|