@esso0428/pi-subagents 0.17.31 → 0.17.32
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 +11 -0
- package/ROADMAP.md +28 -72
- package/package.json +1 -1
- package/src/abortable.ts +14 -0
- package/src/agent-manager.ts +83 -5
- package/src/agent-recovery.ts +7 -0
- package/src/agent-runner.ts +69 -17
- package/src/agent-types.ts +88 -15
- package/src/child-context.ts +12 -0
- package/src/custom-agents.ts +12 -0
- package/src/index.ts +38 -7
- package/src/model-scope.ts +35 -0
- package/src/nested-tools.ts +361 -0
- package/src/settings.ts +18 -0
- package/src/types.ts +22 -0
- package/src/ui/agent-widget.ts +148 -63
- package/src/ui/conversation-viewer.ts +7 -3
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
type AgentSession,
|
|
4
|
+
defineTool,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
type ToolDefinition,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Type } from "@sinclair/typebox";
|
|
10
|
+
import { abortable } from "./abortable.js";
|
|
11
|
+
import { agentHistoryLocator, createAgentHistoryPath } from "./agent-history.js";
|
|
12
|
+
import {
|
|
13
|
+
buildAgentRegistry,
|
|
14
|
+
getAgentConfig,
|
|
15
|
+
getAgentConfigIn,
|
|
16
|
+
getAvailableTypes,
|
|
17
|
+
getAvailableTypesIn,
|
|
18
|
+
resolveEnabledTypeIn,
|
|
19
|
+
resolveTypeIn,
|
|
20
|
+
} from "./agent-types.js";
|
|
21
|
+
import { loadCustomAgents } from "./custom-agents.js";
|
|
22
|
+
import { resolveAgentInvocationConfig } from "./invocation-config.js";
|
|
23
|
+
import { resolveModel } from "./model-resolver.js";
|
|
24
|
+
import { checkModelScope } from "./model-scope.js";
|
|
25
|
+
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
26
|
+
import { getStatusNote } from "./status-note.js";
|
|
27
|
+
import type { AgentConfig, AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "./types.js";
|
|
28
|
+
import { addUsage } from "./usage.js";
|
|
29
|
+
|
|
30
|
+
let maxSubagentDepth = 2;
|
|
31
|
+
|
|
32
|
+
export function getMaxSubagentDepth(): number { return maxSubagentDepth; }
|
|
33
|
+
export function setMaxSubagentDepth(n: number): void { maxSubagentDepth = Math.max(0, Math.floor(n)); }
|
|
34
|
+
|
|
35
|
+
const NESTED_TOOL_NAMES = ["Agent", "wait_for_nested_agent", "steer_subagent"] as const;
|
|
36
|
+
|
|
37
|
+
export interface NestedSpawnOptions {
|
|
38
|
+
description: string;
|
|
39
|
+
model?: Model<Api>;
|
|
40
|
+
maxTurns?: number;
|
|
41
|
+
isolated?: boolean;
|
|
42
|
+
inheritContext?: boolean;
|
|
43
|
+
thinkingLevel?: ThinkingLevel;
|
|
44
|
+
isBackground?: boolean;
|
|
45
|
+
isolation?: IsolationMode;
|
|
46
|
+
invocation?: AgentInvocation;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
49
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
50
|
+
onSpawned?: (id: string) => void;
|
|
51
|
+
depth: number;
|
|
52
|
+
parentAgentId: string;
|
|
53
|
+
maxSubagentDepth: number;
|
|
54
|
+
configCwd?: string;
|
|
55
|
+
rootSessionId?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface NestedAgentManager {
|
|
59
|
+
spawn(pi: ExtensionAPI, ctx: ExtensionContext, type: string, prompt: string, options: NestedSpawnOptions): string;
|
|
60
|
+
spawnAndWait(
|
|
61
|
+
pi: ExtensionAPI,
|
|
62
|
+
ctx: ExtensionContext,
|
|
63
|
+
type: string,
|
|
64
|
+
prompt: string,
|
|
65
|
+
options: Omit<NestedSpawnOptions, "isBackground">,
|
|
66
|
+
onSpawned?: (id: string) => void,
|
|
67
|
+
): Promise<{ id: string; record: AgentRecord }>;
|
|
68
|
+
getRecord(id: string): AgentRecord | undefined;
|
|
69
|
+
resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
|
|
70
|
+
setTranscript?(id: string, historyFile: string, transcriptPath: string, cwd?: string): void;
|
|
71
|
+
reportNestedIssue?(parentAgentId: string, issue: string): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface NestedToolContext {
|
|
75
|
+
manager: NestedAgentManager;
|
|
76
|
+
pi: ExtensionAPI;
|
|
77
|
+
parentAgentId: string;
|
|
78
|
+
depth: number;
|
|
79
|
+
maxSubagentDepth: number;
|
|
80
|
+
allowedSubagents: "all" | string[];
|
|
81
|
+
configCwd: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function textResult(text: string, isError = false) {
|
|
85
|
+
return { content: [{ type: "text" as const, text }], isError, details: {} };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ownsRecord(record: AgentRecord | undefined, parentAgentId: string): record is AgentRecord {
|
|
89
|
+
return record?.parentAgentId === parentAgentId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function partialOutputSuffix(record: AgentRecord): string {
|
|
93
|
+
const partial = record.result?.trim();
|
|
94
|
+
return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatRecord(record: AgentRecord, fetched: boolean): string {
|
|
98
|
+
if (record.status === "error") {
|
|
99
|
+
return `Agent failed: ${record.error ?? "unknown error"}${partialOutputSuffix(record)}`;
|
|
100
|
+
}
|
|
101
|
+
if (record.status === "queued" || record.status === "running") return `Agent ${record.id} is ${record.status}.`;
|
|
102
|
+
const text = record.result?.trim() || record.error?.trim() || "No output.";
|
|
103
|
+
const note = getStatusNote(record.status);
|
|
104
|
+
if (!note) return text;
|
|
105
|
+
return fetched ? `Nested agent${note}.\n\n${text}` : `Nested agent${note}.\n\n${text}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build orchestration tools bound to exactly one owning parent agent. */
|
|
109
|
+
export function createNestedSubagentTools(context: NestedToolContext): ToolDefinition[] {
|
|
110
|
+
if (context.depth >= context.maxSubagentDepth) return [];
|
|
111
|
+
const loadRegistry = () => {
|
|
112
|
+
const registry = buildAgentRegistry(loadCustomAgents(context.configCwd));
|
|
113
|
+
// Types registered at runtime — by pi or another extension — are reachable
|
|
114
|
+
// from a top-level spawn but absent from the config-derived map, which would
|
|
115
|
+
// make nested hard-reject a name the caller can already dispatch elsewhere.
|
|
116
|
+
// configCwd still wins; this only fills genuine gaps.
|
|
117
|
+
for (const name of getAvailableTypes()) {
|
|
118
|
+
if (registry.has(name)) continue;
|
|
119
|
+
const config = getAgentConfig(name);
|
|
120
|
+
if (config) registry.set(name, config);
|
|
121
|
+
}
|
|
122
|
+
return registry;
|
|
123
|
+
};
|
|
124
|
+
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
|
|
125
|
+
context.allowedSubagents === "all"
|
|
126
|
+
? undefined
|
|
127
|
+
: new Set(context.allowedSubagents.map(name => resolveTypeIn(registry, name) ?? name));
|
|
128
|
+
const availableIn = (registry: Map<string, AgentConfig>): string[] => {
|
|
129
|
+
const allowed = allowedTypesIn(registry);
|
|
130
|
+
return getAvailableTypesIn(registry).filter(name => allowed === undefined || allowed.has(name));
|
|
131
|
+
};
|
|
132
|
+
const report = (message: string) => context.manager.reportNestedIssue?.(context.parentAgentId, message);
|
|
133
|
+
|
|
134
|
+
const agentTool = defineTool({
|
|
135
|
+
name: NESTED_TOOL_NAMES[0],
|
|
136
|
+
label: "Agent",
|
|
137
|
+
description:
|
|
138
|
+
"Launch an ownership-scoped nested subagent. Only types in this agent's allowed_subagents may be used; "
|
|
139
|
+
+ "unknown, disabled, and out-of-list types are rejected rather than fallen back.\n\n"
|
|
140
|
+
+ "By default this BLOCKS until the child finishes and returns its result inline. Use that when you need the "
|
|
141
|
+
+ "answer to continue your own task. Set run_in_background: true to run the child detached instead — you get "
|
|
142
|
+
+ "the child id immediately and collect the outcome later with wait_for_nested_agent, which blocks on your "
|
|
143
|
+
+ "behalf. Run several detached children in parallel and wait for them when you are ready.",
|
|
144
|
+
parameters: Type.Object({
|
|
145
|
+
prompt: Type.String({ description: "Self-contained task for the nested agent." }),
|
|
146
|
+
description: Type.String({ description: "Short 3-5 word task description." }),
|
|
147
|
+
subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.` }),
|
|
148
|
+
model: Type.Optional(Type.String({ description: "Optional provider/model override." })),
|
|
149
|
+
thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
|
|
150
|
+
max_turns: Type.Optional(Type.Number({ minimum: 1 })),
|
|
151
|
+
run_in_background: Type.Optional(Type.Boolean({ description: "Run detached and collect it later with wait_for_nested_agent." })),
|
|
152
|
+
resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
|
|
153
|
+
isolated: Type.Optional(Type.Boolean()),
|
|
154
|
+
inherit_context: Type.Optional(Type.Boolean()),
|
|
155
|
+
isolation: Type.Optional(Type.Literal("worktree")),
|
|
156
|
+
}),
|
|
157
|
+
execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
|
|
158
|
+
if (params.resume) {
|
|
159
|
+
const existing = context.manager.getRecord(params.resume);
|
|
160
|
+
if (!ownsRecord(existing, context.parentAgentId)) {
|
|
161
|
+
const message = `Nested agent not found or not owned by this parent: "${params.resume}".`;
|
|
162
|
+
report(`ownership violation: ${message}`);
|
|
163
|
+
return textResult(message, true);
|
|
164
|
+
}
|
|
165
|
+
const resumed = await context.manager.resume(params.resume, params.prompt, signal);
|
|
166
|
+
return resumed
|
|
167
|
+
? textResult(formatRecord(resumed, false), resumed.status === "error")
|
|
168
|
+
: textResult(`Failed to resume nested agent "${params.resume}".`, true);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (context.depth >= context.maxSubagentDepth) {
|
|
172
|
+
const message = `Nested subagent call blocked (depth=${context.depth}, max=${context.maxSubagentDepth}). Complete the task directly.`;
|
|
173
|
+
report(`depth cap: ${message}`);
|
|
174
|
+
return textResult(message, true);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const registry = loadRegistry();
|
|
178
|
+
const rawType = params.subagent_type;
|
|
179
|
+
const resolvedType = resolveEnabledTypeIn(registry, rawType);
|
|
180
|
+
if (resolvedType === undefined) {
|
|
181
|
+
const message = `Unknown or disabled nested agent type: "${rawType}". Allowed: ${availableIn(registry).join(", ") || "none"}.`;
|
|
182
|
+
report(`allowlist rejection: ${message}`);
|
|
183
|
+
return textResult(message, true);
|
|
184
|
+
}
|
|
185
|
+
const allowed = allowedTypesIn(registry);
|
|
186
|
+
if (allowed !== undefined && !allowed.has(resolvedType)) {
|
|
187
|
+
const message = `Nested agent type "${resolvedType}" is not allowed for this parent. Allowed: ${[...allowed].join(", ") || "none"}.`;
|
|
188
|
+
report(`allowlist rejection: ${message}`);
|
|
189
|
+
return textResult(message, true);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const config = getAgentConfigIn(registry, resolvedType);
|
|
193
|
+
const invocation = resolveAgentInvocationConfig(config, params);
|
|
194
|
+
let model = ctx.model;
|
|
195
|
+
if (invocation.modelInput) {
|
|
196
|
+
const resolvedModel = resolveModel(invocation.modelInput, ctx.modelRegistry);
|
|
197
|
+
if (typeof resolvedModel === "string") {
|
|
198
|
+
if (invocation.modelFromParams) return textResult(resolvedModel, true);
|
|
199
|
+
} else {
|
|
200
|
+
model = resolvedModel;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const scopeVerdict = checkModelScope({
|
|
204
|
+
model,
|
|
205
|
+
cwd: context.configCwd,
|
|
206
|
+
modelRegistry: ctx.modelRegistry,
|
|
207
|
+
callerSupplied: invocation.modelFromParams,
|
|
208
|
+
agentLabel: config?.displayName ?? resolvedType,
|
|
209
|
+
modelInput: invocation.modelInput,
|
|
210
|
+
});
|
|
211
|
+
if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message, true);
|
|
212
|
+
|
|
213
|
+
const childDepth = context.depth + 1;
|
|
214
|
+
const rootSessionId = context.manager.getRecord(context.parentAgentId)?.rootSessionId
|
|
215
|
+
?? ctx.sessionManager?.getSessionId?.();
|
|
216
|
+
const options: NestedSpawnOptions = {
|
|
217
|
+
description: params.description,
|
|
218
|
+
model,
|
|
219
|
+
maxTurns: invocation.maxTurns,
|
|
220
|
+
isolated: invocation.isolated,
|
|
221
|
+
inheritContext: invocation.inheritContext,
|
|
222
|
+
thinkingLevel: invocation.thinking,
|
|
223
|
+
isolation: invocation.isolation,
|
|
224
|
+
invocation: {
|
|
225
|
+
thinking: invocation.thinking,
|
|
226
|
+
maxTurns: invocation.maxTurns,
|
|
227
|
+
isolated: invocation.isolated,
|
|
228
|
+
inheritContext: invocation.inheritContext,
|
|
229
|
+
runInBackground: invocation.runInBackground,
|
|
230
|
+
isolation: invocation.isolation,
|
|
231
|
+
},
|
|
232
|
+
depth: childDepth,
|
|
233
|
+
parentAgentId: context.parentAgentId,
|
|
234
|
+
maxSubagentDepth: context.maxSubagentDepth,
|
|
235
|
+
configCwd: context.configCwd,
|
|
236
|
+
rootSessionId,
|
|
237
|
+
onAssistantUsage: usage => {
|
|
238
|
+
for (let id: string | undefined = context.parentAgentId; id !== undefined;) {
|
|
239
|
+
const ancestor = context.manager.getRecord(id);
|
|
240
|
+
if (!ancestor) break;
|
|
241
|
+
addUsage(ancestor.lifetimeUsage, usage);
|
|
242
|
+
id = ancestor.parentAgentId;
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
let childId: string | undefined;
|
|
248
|
+
const attachTranscript = (id: string): void => {
|
|
249
|
+
childId = id;
|
|
250
|
+
if (!rootSessionId || config?.outputTranscript === false) return;
|
|
251
|
+
const record = context.manager.getRecord(id);
|
|
252
|
+
if (!record) return;
|
|
253
|
+
// Idempotent: onSpawned attaches, and the detached path also calls this
|
|
254
|
+
// after spawn returns. Re-running would rewrite the opening entry over
|
|
255
|
+
// the conversation the stream has already appended.
|
|
256
|
+
if (record.historyFile) return;
|
|
257
|
+
record.outputFile = createOutputFilePath(context.configCwd, id, rootSessionId);
|
|
258
|
+
writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
|
|
259
|
+
|
|
260
|
+
// A durable transcript is what keeps the record alive past the manager's
|
|
261
|
+
// cleanup TTL and what `canOpenAgentHistory` reads once the live session
|
|
262
|
+
// is released. Without it `cleanup()` removes the child outright and the
|
|
263
|
+
// parent's row loses its subtree. Filed beside its ancestors' under the
|
|
264
|
+
// root session's directory, matching the top-level agent path.
|
|
265
|
+
try {
|
|
266
|
+
record.historyFile = createAgentHistoryPath(context.configCwd, id);
|
|
267
|
+
record.transcriptPath = agentHistoryLocator(context.configCwd, record.historyFile);
|
|
268
|
+
writeInitialEntry(record.historyFile, id, params.prompt, ctx.cwd);
|
|
269
|
+
context.manager.setTranscript?.(id, record.historyFile, record.transcriptPath, context.configCwd);
|
|
270
|
+
} catch {
|
|
271
|
+
record.historyFile = undefined;
|
|
272
|
+
record.transcriptPath = undefined;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
options.onSessionCreated = session => {
|
|
276
|
+
const record = childId ? context.manager.getRecord(childId) : undefined;
|
|
277
|
+
if (record?.outputFile && childId) {
|
|
278
|
+
// The fifth argument is the durable transcript. Without it the child's
|
|
279
|
+
// conversation never reaches `historyFile`, so once the live session is
|
|
280
|
+
// released the viewer rebuilds from a transcript holding only the prompt.
|
|
281
|
+
record.outputCleanup = streamToOutputFile(session, record.outputFile, childId, ctx.cwd, record.historyFile);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
if (invocation.runInBackground) {
|
|
287
|
+
// onSpawned runs synchronously inside spawn(), before the session is
|
|
288
|
+
// created. Attaching afterwards would set `childId` too late for
|
|
289
|
+
// onSessionCreated to wire the transcript stream, leaving the durable
|
|
290
|
+
// transcript with nothing but the opening prompt.
|
|
291
|
+
const id = context.manager.spawn(context.pi, ctx, resolvedType, params.prompt, {
|
|
292
|
+
...options,
|
|
293
|
+
isBackground: true,
|
|
294
|
+
onSpawned: (spawnedId: string) => {
|
|
295
|
+
attachTranscript(spawnedId);
|
|
296
|
+
options.onSpawned?.(spawnedId);
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
attachTranscript(id);
|
|
300
|
+
return textResult(`Nested agent started in background. Agent ID: ${id}`);
|
|
301
|
+
}
|
|
302
|
+
const { record } = await context.manager.spawnAndWait(context.pi, ctx, resolvedType, params.prompt, options, attachTranscript);
|
|
303
|
+
return textResult(formatRecord(record, false), record.status === "error");
|
|
304
|
+
} catch (err) {
|
|
305
|
+
return textResult(err instanceof Error ? err.message : String(err), true);
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const resultTool = defineTool({
|
|
311
|
+
name: NESTED_TOOL_NAMES[1],
|
|
312
|
+
label: "Wait for Nested Agent",
|
|
313
|
+
description:
|
|
314
|
+
"Block until a nested agent you own finishes, then return its result. This tool always waits — there is no "
|
|
315
|
+
+ "non-blocking variant and no flag to set. Use it for a child you launched with run_in_background: true; a "
|
|
316
|
+
+ "child launched in the foreground has already returned its result inline.\n\n"
|
|
317
|
+
+ "Only ids of nested agents owned by you are accepted. The wait is interruptible: if your own turn is "
|
|
318
|
+
+ "interrupted, this returns without cancelling the child.",
|
|
319
|
+
parameters: Type.Object({ agent_id: Type.String({ description: "The nested agent id returned by Agent." }) }),
|
|
320
|
+
execute: async (_toolCallId, params, signal) => {
|
|
321
|
+
const record = context.manager.getRecord(params.agent_id);
|
|
322
|
+
if (!ownsRecord(record, context.parentAgentId)) {
|
|
323
|
+
const message = `Nested agent not found or not owned by this parent: "${params.agent_id}".`;
|
|
324
|
+
report(`ownership violation: ${message}`);
|
|
325
|
+
return textResult(message, true);
|
|
326
|
+
}
|
|
327
|
+
if ((record.status === "queued" || record.status === "running") && record.promise) {
|
|
328
|
+
await abortable(record.promise, signal);
|
|
329
|
+
}
|
|
330
|
+
return textResult(formatRecord(record, true), record.status === "error");
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const steerTool = defineTool({
|
|
335
|
+
name: NESTED_TOOL_NAMES[2],
|
|
336
|
+
label: "Steer Nested Agent",
|
|
337
|
+
description: "Send guidance to a running nested agent owned by this parent.",
|
|
338
|
+
parameters: Type.Object({ agent_id: Type.String(), message: Type.String() }),
|
|
339
|
+
execute: async (_toolCallId, params) => {
|
|
340
|
+
const record = context.manager.getRecord(params.agent_id);
|
|
341
|
+
if (!ownsRecord(record, context.parentAgentId) || record.status !== "running") {
|
|
342
|
+
const message = `Running nested agent not found or not owned by this parent: "${params.agent_id}".`;
|
|
343
|
+
report(`ownership violation: ${message}`);
|
|
344
|
+
return textResult(message, true);
|
|
345
|
+
}
|
|
346
|
+
if (!record.session) {
|
|
347
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
348
|
+
record.pendingSteers.push(params.message);
|
|
349
|
+
return textResult(`Steering message queued for nested agent ${params.agent_id}.`);
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
await record.session.steer(params.message);
|
|
353
|
+
return textResult(`Steering message sent to nested agent ${params.agent_id}.`);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
return textResult(`Failed to steer nested agent: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
return [agentTool, resultTool, steerTool];
|
|
361
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -9,6 +9,10 @@ import type { JoinMode, WidgetMode } from "./types.js";
|
|
|
9
9
|
|
|
10
10
|
export interface SubagentsSettings {
|
|
11
11
|
maxConcurrent?: number;
|
|
12
|
+
/** Maximum delegation depth. Top-level subagents are depth 1; default is 2. */
|
|
13
|
+
maxSubagentDepth?: number;
|
|
14
|
+
/** Top-level fallback agent type; "none" makes unknown types fail closed. */
|
|
15
|
+
fallbackSubagent?: string;
|
|
12
16
|
/**
|
|
13
17
|
* 0 = unlimited — the extension's single source of truth for that convention:
|
|
14
18
|
* `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
|
|
@@ -95,6 +99,8 @@ export type ToolDescriptionMode = "full" | "compact" | "custom";
|
|
|
95
99
|
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
96
100
|
export interface SettingsAppliers {
|
|
97
101
|
setMaxConcurrent: (n: number) => void;
|
|
102
|
+
setMaxSubagentDepth?: (n: number) => void;
|
|
103
|
+
setFallbackSubagent?: (value: string | undefined) => void;
|
|
98
104
|
setDefaultMaxTurns: (n: number) => void;
|
|
99
105
|
setGraceTurns: (n: number) => void;
|
|
100
106
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
@@ -125,6 +131,16 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
125
131
|
if (!raw || typeof raw !== "object") return {};
|
|
126
132
|
const r = raw as Record<string, unknown>;
|
|
127
133
|
const out: SubagentsSettings = {};
|
|
134
|
+
if (
|
|
135
|
+
Number.isInteger(r.maxSubagentDepth) &&
|
|
136
|
+
(r.maxSubagentDepth as number) >= 0 &&
|
|
137
|
+
(r.maxSubagentDepth as number) <= 100
|
|
138
|
+
) {
|
|
139
|
+
out.maxSubagentDepth = r.maxSubagentDepth as number;
|
|
140
|
+
}
|
|
141
|
+
if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
|
|
142
|
+
out.fallbackSubagent = r.fallbackSubagent.trim();
|
|
143
|
+
}
|
|
128
144
|
if (
|
|
129
145
|
Number.isInteger(r.maxConcurrent) &&
|
|
130
146
|
(r.maxConcurrent as number) >= 1 &&
|
|
@@ -218,6 +234,8 @@ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()):
|
|
|
218
234
|
/** Apply persisted settings to the in-memory state via caller-supplied setters. */
|
|
219
235
|
export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void {
|
|
220
236
|
if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
|
|
237
|
+
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth?.(s.maxSubagentDepth);
|
|
238
|
+
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent?.(s.fallbackSubagent);
|
|
221
239
|
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
222
240
|
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
223
241
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
package/src/types.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface AgentConfig {
|
|
|
47
47
|
outputTranscript?: boolean;
|
|
48
48
|
/** Optional session directory used when persistSession is true. Omitted = pi's normal session location. */
|
|
49
49
|
sessionDir?: string;
|
|
50
|
+
/** Nested delegation, off by default: "all" or an explicit type allowlist. */
|
|
51
|
+
allowedSubagents?: "all" | string[];
|
|
50
52
|
systemPrompt: string;
|
|
51
53
|
promptMode: "replace" | "append";
|
|
52
54
|
/** Default for spawn: fork parent conversation. undefined = caller decides. */
|
|
@@ -134,6 +136,26 @@ export interface AgentRecord {
|
|
|
134
136
|
isBackground?: boolean;
|
|
135
137
|
/** Resolved spawn params, captured for UI display. Fixed at spawn time. */
|
|
136
138
|
invocation?: AgentInvocation;
|
|
139
|
+
/** Nesting depth: main session is 0, top-level subagents are 1. */
|
|
140
|
+
depth?: number;
|
|
141
|
+
/** Parent agent ID for ownership-scoped nested controls. */
|
|
142
|
+
parentAgentId?: string;
|
|
143
|
+
/** Parent description shown in a nested conversation viewer. */
|
|
144
|
+
parentDescription?: string;
|
|
145
|
+
/** Effective inherited nesting cap for this branch. */
|
|
146
|
+
maxSubagentDepth?: number;
|
|
147
|
+
/** Root session id used for nested output transcripts. */
|
|
148
|
+
rootSessionId?: string;
|
|
149
|
+
/** Most recent live activity, including nested rows not tracked by the root UI callback. */
|
|
150
|
+
liveActivity?: {
|
|
151
|
+
activeTools: Map<string, string>;
|
|
152
|
+
responseText: string;
|
|
153
|
+
turnCount: number;
|
|
154
|
+
maxTurns?: number;
|
|
155
|
+
session?: AgentSession;
|
|
156
|
+
};
|
|
157
|
+
/** Latest nested delegation refusal, retained so the widget can explain it. */
|
|
158
|
+
nestedIssue?: string;
|
|
137
159
|
}
|
|
138
160
|
|
|
139
161
|
export interface AgentInvocation {
|