@gotgenes/pi-subagents 15.0.2 → 16.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +24 -24
- package/docs/architecture/architecture.md +111 -18
- package/docs/plans/0381-replace-concurrency-queue-with-limiter.md +267 -0
- package/docs/plans/0400-include-parent-prompt-in-replace-mode.md +199 -0
- package/docs/retro/0381-replace-concurrency-queue-with-limiter.md +49 -0
- package/docs/retro/0400-include-parent-prompt-in-replace-mode.md +84 -0
- package/package.json +1 -1
- package/src/index.ts +8 -15
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/subagent-manager.ts +38 -35
- package/src/lifecycle/subagent.ts +2 -1
- package/src/session/prompts.ts +25 -20
- package/src/lifecycle/concurrency-queue.ts +0 -63
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { AgentTypeRegistry } from "#src/config/agent-types";
|
|
|
24
24
|
import { loadCustomAgents } from "#src/config/custom-agents";
|
|
25
25
|
import { SessionLifecycleHandler, ToolStartHandler } from "#src/handlers/index";
|
|
26
26
|
import { createChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
|
|
27
|
-
import {
|
|
27
|
+
import { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
28
28
|
import { createSubagentSession, type SubagentSessionDeps } from "#src/lifecycle/create-subagent-session";
|
|
29
29
|
import { buildParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
30
30
|
import { SubagentManager, type SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
|
|
@@ -66,12 +66,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
66
66
|
);
|
|
67
67
|
|
|
68
68
|
// Settings: owns all three in-memory values and handles load/save/emit.
|
|
69
|
-
// onMaxConcurrentChanged is wired to the
|
|
69
|
+
// onMaxConcurrentChanged is wired to the limiter directly (closure captures by reference).
|
|
70
70
|
const settings = new SettingsManager({
|
|
71
71
|
emit: (event, payload) => pi.events.emit(event, payload),
|
|
72
72
|
cwd: process.cwd(),
|
|
73
73
|
agentDir: getAgentDir(),
|
|
74
|
-
onMaxConcurrentChanged: () =>
|
|
74
|
+
onMaxConcurrentChanged: () => limiter.recheck(),
|
|
75
75
|
});
|
|
76
76
|
settings.load();
|
|
77
77
|
|
|
@@ -122,7 +122,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
122
122
|
});
|
|
123
123
|
},
|
|
124
124
|
onSubagentCreated(record) {
|
|
125
|
-
// Emit created event for background agents (before
|
|
125
|
+
// Emit created event for background agents (before limiter admission).
|
|
126
126
|
pi.events.emit("subagents:created", {
|
|
127
127
|
id: record.id,
|
|
128
128
|
type: record.type,
|
|
@@ -150,22 +150,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
150
150
|
lifecycle: createChildLifecyclePublisher((channel, data) => pi.events.emit(channel, data)),
|
|
151
151
|
};
|
|
152
152
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
const
|
|
156
|
-
() => settings.maxConcurrent,
|
|
157
|
-
(id) => {
|
|
158
|
-
const agent = manager.getRecord(id);
|
|
159
|
-
if (agent?.status !== "queued") return;
|
|
160
|
-
agent.promise = agent.run();
|
|
161
|
-
},
|
|
162
|
-
);
|
|
153
|
+
// ConcurrencyLimiter: schedules background run thunks FIFO against the limit.
|
|
154
|
+
// It knows nothing about agents or the manager — dependency direction is strictly manager → limiter.
|
|
155
|
+
const limiter = new ConcurrencyLimiter(() => settings.maxConcurrent);
|
|
163
156
|
|
|
164
157
|
const manager = new SubagentManager({
|
|
165
158
|
createSubagentSession: (params) => createSubagentSession(params, subagentSessionDeps),
|
|
166
159
|
baseCwd: process.cwd(),
|
|
167
160
|
observer,
|
|
168
|
-
|
|
161
|
+
limiter,
|
|
169
162
|
getRunConfig: () => settings,
|
|
170
163
|
});
|
|
171
164
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* concurrency-limiter.ts — FIFO admission gate for background work.
|
|
3
|
+
*
|
|
4
|
+
* Schedules run closures (thunks) against a dynamic limit, running them in
|
|
5
|
+
* scheduling order as slots free. The limiter knows nothing about agents, IDs,
|
|
6
|
+
* or the manager — it owns only the active count and the pending queue.
|
|
7
|
+
*
|
|
8
|
+
* Every scheduled promise settles: it follows the task's settlement when the
|
|
9
|
+
* task runs, or resolves early if clear() drops it before it starts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class ConcurrencyLimiter {
|
|
13
|
+
private active = 0;
|
|
14
|
+
private readonly pending: Array<{ start: () => void; settle: () => void }> = [];
|
|
15
|
+
|
|
16
|
+
constructor(private readonly getLimit: () => number) {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Schedule a task to run FIFO once a slot is free.
|
|
20
|
+
* Returns a promise that settles with the task, or resolves early if the
|
|
21
|
+
* task is dropped by clear() before it starts.
|
|
22
|
+
*/
|
|
23
|
+
schedule(task: () => Promise<void>): Promise<void> {
|
|
24
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>(); // eslint-disable-line @typescript-eslint/no-invalid-void-type -- Promise.withResolvers<void> is valid; rule does not allow void in generic fn call type args
|
|
25
|
+
this.pending.push({
|
|
26
|
+
start: () => {
|
|
27
|
+
this.active++;
|
|
28
|
+
task()
|
|
29
|
+
.then(resolve, reject)
|
|
30
|
+
.finally(() => {
|
|
31
|
+
this.active--;
|
|
32
|
+
this.recheck();
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
settle: resolve,
|
|
36
|
+
});
|
|
37
|
+
this.recheck();
|
|
38
|
+
return promise;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Start pending tasks until the limit is reached. Call when the limit may have grown. */
|
|
42
|
+
recheck(): void {
|
|
43
|
+
while (this.active < this.getLimit()) {
|
|
44
|
+
const next = this.pending.shift();
|
|
45
|
+
if (!next) break;
|
|
46
|
+
next.start();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Drop all pending tasks, resolving their promises without running them. */
|
|
51
|
+
clear(): void {
|
|
52
|
+
const dropped = this.pending.splice(0);
|
|
53
|
+
for (const task of dropped) task.settle();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* subagent-manager.ts - Tracks subagents, background execution, resume support.
|
|
3
3
|
*
|
|
4
4
|
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
|
-
* Excess agents are
|
|
6
|
-
* Foreground agents bypass the
|
|
5
|
+
* Excess agents are scheduled on a ConcurrencyLimiter and auto-started as running
|
|
6
|
+
* agents complete. Foreground agents bypass the limiter (they block the parent anyway).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
11
|
import { debugLog } from "#src/debug";
|
|
12
|
-
import type {
|
|
12
|
+
import type { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
13
13
|
import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
|
|
14
14
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
15
15
|
import { Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent";
|
|
@@ -31,8 +31,8 @@ export interface SubagentManagerObserver {
|
|
|
31
31
|
export interface SubagentManagerOptions {
|
|
32
32
|
/** Assembly factory that produces a born-complete SubagentSession per spawn. */
|
|
33
33
|
createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
34
|
-
/** Concurrency
|
|
35
|
-
|
|
34
|
+
/** Concurrency limiter — schedules background run thunks FIFO against the limit. */
|
|
35
|
+
limiter: ConcurrencyLimiter;
|
|
36
36
|
/** Base working directory handed to a workspace provider (the parent cwd). */
|
|
37
37
|
baseCwd: string;
|
|
38
38
|
getRunConfig?: () => RunConfig;
|
|
@@ -67,7 +67,7 @@ export class SubagentManager {
|
|
|
67
67
|
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
68
68
|
private readonly observer?: SubagentManagerObserver;
|
|
69
69
|
private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
70
|
-
private readonly
|
|
70
|
+
private readonly limiter: ConcurrencyLimiter;
|
|
71
71
|
private readonly baseCwd: string;
|
|
72
72
|
private getRunConfig?: () => RunConfig;
|
|
73
73
|
private _workspaceProvider?: WorkspaceProvider;
|
|
@@ -79,7 +79,7 @@ export class SubagentManager {
|
|
|
79
79
|
|
|
80
80
|
constructor(options: SubagentManagerOptions) {
|
|
81
81
|
this.createSubagentSession = options.createSubagentSession;
|
|
82
|
-
this.
|
|
82
|
+
this.limiter = options.limiter;
|
|
83
83
|
this.baseCwd = options.baseCwd;
|
|
84
84
|
this.observer = options.observer;
|
|
85
85
|
this.getRunConfig = options.getRunConfig;
|
|
@@ -109,7 +109,6 @@ export class SubagentManager {
|
|
|
109
109
|
private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver {
|
|
110
110
|
return {
|
|
111
111
|
onStarted: (agent) => {
|
|
112
|
-
if (options.isBackground) this.queue.markStarted();
|
|
113
112
|
this.observer?.onSubagentStarted(agent);
|
|
114
113
|
},
|
|
115
114
|
onSessionCreated: options.observer?.onSessionCreated
|
|
@@ -117,7 +116,6 @@ export class SubagentManager {
|
|
|
117
116
|
: undefined,
|
|
118
117
|
onRunFinished: (agent) => {
|
|
119
118
|
if (options.isBackground) {
|
|
120
|
-
this.queue.markFinished();
|
|
121
119
|
try { this.observer?.onSubagentCompleted(agent); } catch (err) { debugLog("onSubagentCompleted observer", err); }
|
|
122
120
|
}
|
|
123
121
|
},
|
|
@@ -166,9 +164,13 @@ export class SubagentManager {
|
|
|
166
164
|
this.observer?.onSubagentCreated(record);
|
|
167
165
|
}
|
|
168
166
|
|
|
169
|
-
if (options.isBackground && !options.bypassQueue
|
|
170
|
-
//
|
|
171
|
-
|
|
167
|
+
if (options.isBackground && !options.bypassQueue) {
|
|
168
|
+
// Schedule on the limiter — started when a slot frees. The status guard
|
|
169
|
+
// makes an abort-while-queued task a no-op (Step 3 folds it into start()).
|
|
170
|
+
record.promise = this.limiter.schedule(() => {
|
|
171
|
+
if (record.status !== "queued") return Promise.resolve();
|
|
172
|
+
return record.run();
|
|
173
|
+
});
|
|
172
174
|
return id;
|
|
173
175
|
}
|
|
174
176
|
|
|
@@ -221,9 +223,9 @@ export class SubagentManager {
|
|
|
221
223
|
const record = this.agents.get(id);
|
|
222
224
|
if (!record) return false;
|
|
223
225
|
|
|
224
|
-
//
|
|
226
|
+
// A queued agent has not started; mark it stopped. Its scheduled thunk
|
|
227
|
+
// becomes a no-op (status guard) when its slot finally opens.
|
|
225
228
|
if (record.status === "queued") {
|
|
226
|
-
this.queue.dequeue(id);
|
|
227
229
|
record.markStopped();
|
|
228
230
|
return true;
|
|
229
231
|
}
|
|
@@ -269,43 +271,44 @@ export class SubagentManager {
|
|
|
269
271
|
// fallow-ignore-next-line unused-class-member
|
|
270
272
|
abortAll(): number {
|
|
271
273
|
let count = 0;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const record = this.agents.get(id);
|
|
275
|
-
if (record) {
|
|
274
|
+
for (const record of this.agents.values()) {
|
|
275
|
+
if (record.status === "queued") {
|
|
276
276
|
record.markStopped();
|
|
277
277
|
count++;
|
|
278
|
+
} else if (record.abort()) {
|
|
279
|
+
count++;
|
|
278
280
|
}
|
|
279
281
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
for (const record of this.agents.values()) {
|
|
283
|
-
if (record.abort()) count++;
|
|
284
|
-
}
|
|
282
|
+
// Drop pending thunks (their promises resolve).
|
|
283
|
+
this.limiter.clear();
|
|
285
284
|
return count;
|
|
286
285
|
}
|
|
287
286
|
|
|
288
287
|
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
289
288
|
// fallow-ignore-next-line unused-class-member
|
|
290
289
|
async waitForAll(): Promise<void> {
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const pending = [...this.agents.values()]
|
|
297
|
-
.filter(r => r.status === "running" || r.status === "queued")
|
|
298
|
-
.map(r => r.promise)
|
|
299
|
-
.filter((p): p is Promise<void> => p != null);
|
|
300
|
-
if (pending.length === 0) break;
|
|
290
|
+
// Every spawned agent has a settled-on-completion promise (the limiter starts
|
|
291
|
+
// queued ones as slots free), so a single allSettled covers the queued case.
|
|
292
|
+
// The loop only catches agents spawned during the wait.
|
|
293
|
+
let pending = this.pendingPromises();
|
|
294
|
+
while (pending.length > 0) {
|
|
301
295
|
await Promise.allSettled(pending);
|
|
296
|
+
pending = this.pendingPromises();
|
|
302
297
|
}
|
|
303
298
|
}
|
|
304
299
|
|
|
300
|
+
/** Promises of all running/queued agents that have one. */
|
|
301
|
+
private pendingPromises(): Promise<void>[] {
|
|
302
|
+
return [...this.agents.values()]
|
|
303
|
+
.filter(r => r.status === "running" || r.status === "queued")
|
|
304
|
+
.map(r => r.promise)
|
|
305
|
+
.filter((p): p is Promise<void> => p != null);
|
|
306
|
+
}
|
|
307
|
+
|
|
305
308
|
dispose() {
|
|
306
309
|
clearInterval(this.cleanupInterval);
|
|
307
|
-
//
|
|
308
|
-
this.
|
|
310
|
+
// Drop pending thunks
|
|
311
|
+
this.limiter.clear();
|
|
309
312
|
for (const record of this.agents.values()) {
|
|
310
313
|
record.disposeSession();
|
|
311
314
|
}
|
|
@@ -428,7 +428,8 @@ export class Subagent {
|
|
|
428
428
|
/**
|
|
429
429
|
* Abort a running agent: fire AbortController and transition to stopped.
|
|
430
430
|
* Returns false if the agent is not running.
|
|
431
|
-
*
|
|
431
|
+
* A still-queued agent is stopped by SubagentManager; its scheduled thunk
|
|
432
|
+
* then no-ops on the queued-status guard.
|
|
432
433
|
*/
|
|
433
434
|
abort(): boolean {
|
|
434
435
|
if (this._status !== "running") return false;
|
package/src/session/prompts.ts
CHANGED
|
@@ -8,17 +8,25 @@ import type { AgentPromptConfig } from "#src/types";
|
|
|
8
8
|
/**
|
|
9
9
|
* Build the system prompt for an agent from its config.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Both modes place the shared/stable parent prompt (or `genericBase` when no
|
|
12
|
+
* parent is available) first so the LLM's KV cache can reuse the inherited
|
|
13
|
+
* prefix across all subagent invocations.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* - "replace" mode: parent/genericBase + active_agent tag + env header +
|
|
16
|
+
* config.systemPrompt. No `<sub_agent_context>` bridge and no
|
|
17
|
+
* `<agent_instructions>` wrapper — the custom prompt has full control and
|
|
18
|
+
* the final say.
|
|
19
|
+
* - "append" mode: parent/genericBase + sub-agent context bridge +
|
|
20
|
+
* active_agent tag + env header + config.systemPrompt (wrapped in
|
|
21
|
+
* `<agent_instructions>` when non-empty).
|
|
22
|
+
* - "append" with empty systemPrompt: pure parent clone.
|
|
20
23
|
*
|
|
21
|
-
*
|
|
24
|
+
* Both modes include an `<active_agent name="${config.name}"/>` tag so
|
|
25
|
+
* downstream extensions (e.g. `@gotgenes/pi-permission-system`) can resolve
|
|
26
|
+
* per-agent policy inside the child session by parsing the system prompt.
|
|
27
|
+
* The tag follows the cacheable parent prefix in both modes.
|
|
28
|
+
*
|
|
29
|
+
* @param parentSystemPrompt The parent agent's effective system prompt.
|
|
22
30
|
*/
|
|
23
31
|
export function buildAgentPrompt(
|
|
24
32
|
config: AgentPromptConfig,
|
|
@@ -33,8 +41,9 @@ Working directory: ${cwd}
|
|
|
33
41
|
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
|
|
34
42
|
Platform: ${env.platform}`;
|
|
35
43
|
|
|
44
|
+
const identity = parentSystemPrompt ?? genericBase;
|
|
45
|
+
|
|
36
46
|
if (config.promptMode === "append") {
|
|
37
|
-
const identity = parentSystemPrompt ?? genericBase;
|
|
38
47
|
|
|
39
48
|
const bridge = `<sub_agent_context>
|
|
40
49
|
You are operating as a sub-agent invoked to handle a specific task.
|
|
@@ -69,18 +78,14 @@ You are operating as a sub-agent invoked to handle a specific task.
|
|
|
69
78
|
);
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
// "replace" mode —
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return (
|
|
79
|
-
activeAgentTag + replaceHeader + "\n\n" + config.systemPrompt
|
|
80
|
-
);
|
|
81
|
+
// "replace" mode — parent/genericBase prefix first for KV cache reuse, then
|
|
82
|
+
// the active_agent tag, env block, and the config's full system prompt.
|
|
83
|
+
// Unlike append mode, no <sub_agent_context> bridge or <agent_instructions>
|
|
84
|
+
// wrapper is injected — the custom prompt retains full control.
|
|
85
|
+
return identity + "\n\n" + activeAgentTag + envBlock + "\n\n" + config.systemPrompt;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
/** Fallback base prompt when parent system prompt is unavailable
|
|
88
|
+
/** Fallback base prompt when parent system prompt is unavailable (both modes). */
|
|
84
89
|
const genericBase = `# Role
|
|
85
90
|
You are a general-purpose coding agent for complex, multi-step tasks.
|
|
86
91
|
You have full access to read, write, edit files, and execute commands.
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* concurrency-queue.ts — Manages background agent scheduling with a configurable concurrency limit.
|
|
3
|
-
*
|
|
4
|
-
* Stores agent IDs (not full agent objects) and decides *when* to start them.
|
|
5
|
-
* The startAgent callback provided at construction handles the actual agent lifecycle.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export class ConcurrencyQueue {
|
|
9
|
-
private queue: string[] = [];
|
|
10
|
-
private running = 0;
|
|
11
|
-
|
|
12
|
-
constructor(
|
|
13
|
-
private readonly getMaxConcurrent: () => number,
|
|
14
|
-
private readonly startAgent: (id: string) => void,
|
|
15
|
-
) {}
|
|
16
|
-
|
|
17
|
-
/** Whether the concurrency limit has been reached. */
|
|
18
|
-
isFull(): boolean {
|
|
19
|
-
return this.running >= this.getMaxConcurrent();
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Add an agent ID to the wait queue. */
|
|
23
|
-
enqueue(id: string): void {
|
|
24
|
-
this.queue.push(id);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Remove an agent ID from the queue (e.g., aborted before starting). Returns true if found. */
|
|
28
|
-
dequeue(id: string): boolean {
|
|
29
|
-
const idx = this.queue.indexOf(id);
|
|
30
|
-
if (idx === -1) return false;
|
|
31
|
-
this.queue.splice(idx, 1);
|
|
32
|
-
return true;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Increment the running count. Called when an agent transitions to running. */
|
|
36
|
-
markStarted(): void {
|
|
37
|
-
this.running++;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Decrement the running count and drain the queue. Called when a background agent finishes. */
|
|
41
|
-
markFinished(): void {
|
|
42
|
-
this.running--;
|
|
43
|
-
this.drain();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Start queued agents until the concurrency limit is reached. */
|
|
47
|
-
drain(): void {
|
|
48
|
-
while (this.queue.length > 0 && !this.isFull()) {
|
|
49
|
-
const id = this.queue.shift()!;
|
|
50
|
-
this.startAgent(id);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Snapshot of queued IDs for iteration (e.g., abortAll). */
|
|
55
|
-
get queuedIds(): readonly string[] {
|
|
56
|
-
return this.queue;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Clear the queue without starting any agents. */
|
|
60
|
-
clear(): void {
|
|
61
|
-
this.queue = [];
|
|
62
|
-
}
|
|
63
|
-
}
|