@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/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 { ConcurrencyQueue } from "#src/lifecycle/concurrency-queue";
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 queue directly (closure captures by reference).
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: () => queue.drain(),
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 startAgent / queue drain).
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
- // ConcurrencyQueue: scheduling extracted from SubagentManager.
154
- // startAgent callback forward-references manager via closure (safedrain is never called during construction).
155
- const queue = new ConcurrencyQueue(
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
- queue,
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 queued and auto-started as running agents complete.
6
- * Foreground agents bypass the queue (they block the parent anyway).
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 { ConcurrencyQueue } from "#src/lifecycle/concurrency-queue";
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 queueowns scheduling, limit checks, and drain logic. */
35
- queue: ConcurrencyQueue;
34
+ /** Concurrency limiterschedules 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 queue: ConcurrencyQueue;
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.queue = options.queue;
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 && this.queue.isFull()) {
170
- // Queue it - will be started when a running agent completes
171
- this.queue.enqueue(id);
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
- // Remove from queue if queued
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
- // Clear queued agents first
273
- for (const id of this.queue.queuedIds) {
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
- this.queue.clear();
281
- // Abort running agents
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
- // Loop because queue.drain() respects the concurrency limit - as running
292
- // agents finish they start queued ones, which need awaiting too.
293
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with explicit break
294
- while (true) {
295
- this.queue.drain();
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
- // Clear queue
308
- this.queue.clear();
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
- * Queue removal is handled by SubagentManager via ConcurrencyQueue.dequeue().
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;
@@ -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
- * - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
12
- * - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
13
- * - "append" with empty systemPrompt: pure parent clone
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
- * Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
16
- * extensions (e.g. `@gotgenes/pi-permission-system`) can resolve per-agent policy
17
- * inside the child session by parsing the system prompt.
18
- * In replace mode the tag is prepended; in append mode it follows the shared
19
- * inherited content so the stable prefix is cacheable by the LLM's KV cache.
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
- * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
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 — env header + the config's full system prompt
73
- const replaceHeader = `You are a pi coding agent sub-agent.
74
- You have been invoked to handle a specific task autonomously.
75
-
76
- ${envBlock}`;
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 in append mode. */
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
- }