@gotgenes/pi-subagents 16.1.0 → 16.2.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.
@@ -24,8 +24,8 @@ import { debugLog } from "#src/debug";
24
24
  import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
25
25
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
26
26
  import type { SubagentSession, TurnLoopResult } from "#src/lifecycle/subagent-session";
27
+ import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
27
28
  import type { LifetimeUsage } from "#src/lifecycle/usage";
28
- import { addUsage } from "#src/lifecycle/usage";
29
29
  import type { Workspace, WorkspaceProvider } from "#src/lifecycle/workspace";
30
30
  import { NotificationState } from "#src/observation/notification-state";
31
31
  import { subscribeSubagentObserver } from "#src/observation/record-observer";
@@ -44,50 +44,48 @@ export interface SubagentLifecycleObserver {
44
44
  onCompacted?(agent: Subagent, info: CompactionInfo): void;
45
45
  }
46
46
 
47
- export type SubagentStatus =
48
- | "queued"
49
- | "running"
50
- | "completed"
51
- | "steered"
52
- | "aborted"
53
- | "stopped"
54
- | "error";
47
+ export type { SubagentStatus } from "#src/lifecycle/subagent-state";
55
48
 
56
- export interface SubagentInit {
57
- // Identity
58
- id: string;
59
- type: SubagentType;
60
- description: string;
61
- invocation?: AgentInvocation;
62
-
63
- // Status (for tests and restore scenarios)
64
- status?: SubagentStatus;
65
- startedAt?: number;
66
- completedAt?: number;
67
- result?: string;
68
- error?: string;
69
-
70
- // Shared deps (required for run(), optional for tests)
49
+ /**
50
+ * The execution machinery a Subagent needs to run. A single mandatory
51
+ * collaborator: production (SubagentManager.spawn) always supplies it, so run()
52
+ * needs no "not configured" guards. The genuinely-optional behavior knobs stay
53
+ * optional; the four inputs run() cannot proceed without are required.
54
+ */
55
+ export interface SubagentExecution {
71
56
  /** Assembly factory that produces a born-complete SubagentSession. */
72
- createSubagentSession?: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
57
+ createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
58
+ /** Immutable spawn-time parent snapshot handed to the session factory. */
59
+ snapshot: ParentSnapshot;
60
+ /** Initial prompt for the turn loop. */
61
+ prompt: string;
62
+ /** Parent working directory handed to a workspace provider's prepare(). */
63
+ baseCwd: string;
73
64
  observer?: SubagentLifecycleObserver;
74
65
  getRunConfig?: () => RunConfig;
75
66
  /** Resolves the registered workspace provider (if any) at run-start. */
76
67
  getWorkspaceProvider?: () => WorkspaceProvider | undefined;
77
- /** Parent working directory handed to a workspace provider's prepare(). */
78
- baseCwd?: string;
79
-
80
- // Run config (required for run(), optional for tests)
81
- snapshot?: ParentSnapshot;
82
- prompt?: string;
83
68
  model?: Model<any>;
84
69
  maxTurns?: number;
85
70
  thinkingLevel?: ThinkingLevel;
86
71
  parentSession?: ParentSessionInfo;
87
- isBackground?: boolean;
88
72
  signal?: AbortSignal;
89
73
  }
90
74
 
75
+ export interface SubagentInit {
76
+ // Identity
77
+ id: string;
78
+ type: SubagentType;
79
+ description: string;
80
+ invocation?: AgentInvocation;
81
+
82
+ /** Execution machinery — always supplied; construct-complete, no test fallbacks. */
83
+ execution: SubagentExecution;
84
+
85
+ /** Lifecycle status and metrics. Defaults to a fresh queued state. */
86
+ state?: SubagentState;
87
+ }
88
+
91
89
  export class Subagent {
92
90
  // Identity — set once at construction
93
91
  readonly id: string;
@@ -95,59 +93,36 @@ export class Subagent {
95
93
  readonly description: string;
96
94
  readonly invocation?: AgentInvocation;
97
95
 
98
- // Transition stateencapsulated behind getters, mutated only via transition methods
99
- private _status: SubagentStatus;
100
- get status(): SubagentStatus { return this._status; }
101
-
102
- private _result?: string;
103
- get result(): string | undefined { return this._result; }
104
-
105
- private _error?: string;
106
- get error(): string | undefined { return this._error; }
107
-
108
- private _startedAt: number;
109
- get startedAt(): number { return this._startedAt; }
110
-
111
- private _completedAt?: number;
112
- get completedAt(): number | undefined { return this._completedAt; }
113
-
114
- // Stats — accumulated via mutation methods, readable via getters
115
- private _toolUses: number;
116
- get toolUses(): number { return this._toolUses; }
117
-
118
- private _lifetimeUsage: LifetimeUsage;
119
- get lifetimeUsage(): Readonly<LifetimeUsage> { return this._lifetimeUsage; }
120
-
121
- private _compactionCount: number;
122
- get compactionCount(): number { return this._compactionCount; }
96
+ // Lifecycle status and metrics owned by a private value object; getters and
97
+ // mutation methods below delegate to it one line.
98
+ private readonly state: SubagentState;
99
+ get status(): SubagentStatus { return this.state.status; }
100
+ get result(): string | undefined { return this.state.result; }
101
+ get error(): string | undefined { return this.state.error; }
102
+ get startedAt(): number { return this.state.startedAt; }
103
+ get completedAt(): number | undefined { return this.state.completedAt; }
104
+ get toolUses(): number { return this.state.toolUses; }
105
+ get lifetimeUsage(): Readonly<LifetimeUsage> { return this.state.lifetimeUsage; }
106
+ get compactionCount(): number { return this.state.compactionCount; }
123
107
 
124
108
  /** AbortController for cancelling this agent. Created at construction. */
125
109
  readonly abortController: AbortController;
126
- /** Promise for the full agent run (including post-processing). Set by run(). */
127
- promise?: Promise<void>;
128
-
129
- // Shared deps optional (required for run())
130
- private readonly _createSubagentSession?: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
131
- readonly observer?: SubagentLifecycleObserver;
132
- private readonly _getRunConfig?: () => RunConfig;
133
- private readonly _getWorkspaceProvider?: () => WorkspaceProvider | undefined;
134
- private readonly _baseCwd: string;
110
+ /** Backing store for the run promise. Set by start(). */
111
+ private _promise?: Promise<void>;
112
+ /** Awaitable handle to the running promise. Set by start(). */
113
+ get promise(): Promise<void> | undefined { return this._promise; }
114
+
115
+ // Execution machinery — a single mandatory collaborator (no per-field fallbacks).
116
+ private readonly execution: SubagentExecution;
135
117
  /** Workspace prepared at run-start by a provider — undefined when none is registered. */
136
118
  private _workspace?: Workspace;
137
119
 
138
- // Run config — optional (required for run())
139
- private readonly _snapshot?: ParentSnapshot;
140
- private readonly _prompt?: string;
141
- private readonly _model?: Model<any>;
142
- private readonly _maxTurns?: number;
143
- private readonly _thinkingLevel?: ThinkingLevel;
144
- private readonly _parentSession?: ParentSessionInfo;
145
- private readonly _signal?: AbortSignal;
146
-
147
120
  // Phase-specific collaborators — each born complete when their info becomes available
148
121
  /** The born-complete child session — set when the factory returns inside run(). */
149
122
  subagentSession?: SubagentSession;
150
- notification?: NotificationState;
123
+ private _notification?: NotificationState;
124
+ /** Notification state for background agents — wired from parentSession.toolCallId. */
125
+ get notification(): NotificationState | undefined { return this._notification; }
151
126
 
152
127
  // Steer buffer — messages queued before the session is ready
153
128
  private _pendingSteers: string[] = [];
@@ -207,40 +182,19 @@ export class Subagent {
207
182
  this.description = init.description;
208
183
  this.invocation = init.invocation;
209
184
 
210
- // Status
211
- this._status = init.status ?? "queued";
212
- this._result = init.result;
213
- this._error = init.error;
214
- this._startedAt = init.startedAt ?? Date.now();
215
- this._completedAt = init.completedAt;
216
-
217
- // Stats
218
- this._toolUses = 0;
219
- this._lifetimeUsage = { input: 0, output: 0, cacheWrite: 0 };
220
- this._compactionCount = 0;
185
+ // Lifecycle status and metrics — fresh queued state unless one is supplied
186
+ this.state = init.state ?? new SubagentState();
221
187
 
222
188
  // Abort controller — always created, never injected
223
189
  this.abortController = new AbortController();
224
190
 
225
- // Shared deps
226
- this._createSubagentSession = init.createSubagentSession;
227
- this.observer = init.observer;
228
- this._getRunConfig = init.getRunConfig;
229
- this._getWorkspaceProvider = init.getWorkspaceProvider;
230
- this._baseCwd = init.baseCwd ?? "";
231
-
232
- // Run config
233
- this._snapshot = init.snapshot;
234
- this._prompt = init.prompt;
235
- this._model = init.model;
236
- this._maxTurns = init.maxTurns;
237
- this._thinkingLevel = init.thinkingLevel;
238
- this._parentSession = init.parentSession;
239
- this._signal = init.signal;
191
+ // Execution machinery — a single mandatory collaborator
192
+ this.execution = init.execution;
240
193
 
241
194
  // Notification state — created from parentSession.toolCallId if present
242
- if (init.parentSession?.toolCallId) {
243
- this.notification = new NotificationState(init.parentSession.toolCallId);
195
+ const toolCallId = init.execution.parentSession?.toolCallId;
196
+ if (toolCallId) {
197
+ this._notification = new NotificationState(toolCallId);
244
198
  }
245
199
  }
246
200
 
@@ -249,31 +203,25 @@ export class Subagent {
249
203
  * via the factory, observer wiring, the turn loop, workspace disposal, and
250
204
  * status transitions.
251
205
  *
252
- * Requires the session factory and snapshot to be set at construction.
253
- * The returned promise always resolves (errors are captured internally).
206
+ * Execution is supplied at construction (mandatory), so run() needs no
207
+ * "not configured" guards. The returned promise always resolves (errors are
208
+ * captured internally).
254
209
  */
255
210
  async run(): Promise<void> {
256
- if (!this._createSubagentSession) {
257
- throw new Error("Subagent not configured for execution — missing session factory");
258
- }
259
- if (!this._snapshot || !this._prompt) {
260
- throw new Error("Subagent not configured for execution — missing snapshot or prompt");
261
- }
262
-
263
211
  this.markRunning(Date.now());
264
- this.observer?.onStarted?.(this);
265
- this.wireSignal(this._signal, () => this.abort());
212
+ this.execution.observer?.onStarted?.(this);
213
+ this.wireSignal(this.execution.signal, () => this.abort());
266
214
 
267
215
  let cwd: string | undefined;
268
216
  try {
269
217
  // A registered workspace provider supplies the child's cwd and owns its
270
218
  // teardown; with no provider the child runs in the parent cwd.
271
- const provider = this._getWorkspaceProvider?.();
219
+ const provider = this.execution.getWorkspaceProvider?.();
272
220
  if (provider) {
273
221
  this._workspace = await provider.prepare({
274
222
  agentId: this.id,
275
223
  agentType: this.type,
276
- baseCwd: this._baseCwd,
224
+ baseCwd: this.execution.baseCwd,
277
225
  invocation: this.invocation,
278
226
  });
279
227
  cwd = this._workspace?.cwd;
@@ -281,18 +229,18 @@ export class Subagent {
281
229
  } catch (err) {
282
230
  this.markError(err);
283
231
  this.releaseListeners();
284
- this.observer?.onRunFinished?.(this);
232
+ this.execution.observer?.onRunFinished?.(this);
285
233
  return;
286
234
  }
287
235
 
288
236
  try {
289
- this.subagentSession = await this._createSubagentSession({
290
- snapshot: this._snapshot,
237
+ this.subagentSession = await this.execution.createSubagentSession({
238
+ snapshot: this.execution.snapshot,
291
239
  type: this.type,
292
240
  cwd,
293
- parentSession: this._parentSession,
294
- model: this._model,
295
- thinkingLevel: this._thinkingLevel,
241
+ parentSession: this.execution.parentSession,
242
+ model: this.execution.model,
243
+ thinkingLevel: this.execution.thinkingLevel,
296
244
  });
297
245
  } catch (err) {
298
246
  // The factory disposed its own session on a post-creation failure.
@@ -301,15 +249,15 @@ export class Subagent {
301
249
  }
302
250
 
303
251
  this.flushPendingSteers();
304
- this.attachObserver(subscribeSubagentObserver(this.subagentSession, this, {
305
- onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
252
+ this.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
253
+ onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
306
254
  }));
307
- this.observer?.onSessionCreated?.(this);
255
+ this.execution.observer?.onSessionCreated?.(this);
308
256
 
309
- const runConfig = this._getRunConfig?.();
257
+ const runConfig = this.execution.getRunConfig?.();
310
258
  try {
311
- const result = await this.subagentSession.runTurnLoop(this._prompt, {
312
- maxTurns: this._maxTurns,
259
+ const result = await this.subagentSession.runTurnLoop(this.execution.prompt, {
260
+ maxTurns: this.execution.maxTurns,
313
261
  defaultMaxTurns: runConfig?.defaultMaxTurns,
314
262
  graceTurns: runConfig?.graceTurns,
315
263
  signal: this.abortController.signal,
@@ -320,6 +268,22 @@ export class Subagent {
320
268
  }
321
269
  }
322
270
 
271
+ /**
272
+ * Start execution: call run(), store the promise internally, and return it.
273
+ *
274
+ * Guards against non-active states (e.g. abort-while-queued): if the agent
275
+ * is neither queued nor running, the promise resolves immediately (no-op).
276
+ * This folds the inline status guard out of SubagentManager's limiter callback.
277
+ */
278
+ start(): Promise<void> {
279
+ if (this.status !== "queued" && this.status !== "running") {
280
+ this._promise = Promise.resolve();
281
+ return this._promise;
282
+ }
283
+ this._promise = this.run();
284
+ return this._promise;
285
+ }
286
+
323
287
  /**
324
288
  * Resume an existing session with a new prompt, managing the observer
325
289
  * subscription lifecycle internally (same wiring as run()).
@@ -336,8 +300,8 @@ export class Subagent {
336
300
  }
337
301
 
338
302
  this.resetForResume(Date.now());
339
- this.attachObserver(subscribeSubagentObserver(subagentSession, this, {
340
- onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
303
+ this.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
304
+ onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
341
305
  }));
342
306
 
343
307
  try {
@@ -352,23 +316,22 @@ export class Subagent {
352
316
 
353
317
  /** Increment tool use count. Called by record-observer on tool_execution_end. */
354
318
  incrementToolUses(): void {
355
- this._toolUses++;
319
+ this.state.incrementToolUses();
356
320
  }
357
321
 
358
322
  /** Accumulate a usage delta into lifetimeUsage. Called by record-observer on message_end. */
359
323
  addUsage(delta: { input: number; output: number; cacheWrite: number }): void {
360
- addUsage(this._lifetimeUsage, delta);
324
+ this.state.addUsage(delta);
361
325
  }
362
326
 
363
327
  /** Increment compaction count. Called by record-observer on compaction_end. */
364
328
  incrementCompactions(): void {
365
- this._compactionCount++;
329
+ this.state.incrementCompactions();
366
330
  }
367
331
 
368
332
  /** Transition to running state. Sets status and startedAt. */
369
333
  markRunning(startedAt: number): void {
370
- this._status = "running";
371
- this._startedAt = startedAt;
334
+ this.state.markRunning(startedAt);
372
335
  }
373
336
 
374
337
  /**
@@ -376,11 +339,7 @@ export class Subagent {
376
339
  * Always sets result and completedAt (??=). Only changes status if not stopped.
377
340
  */
378
341
  markCompleted(result: string, completedAt?: number): void {
379
- this._result = result;
380
- this._completedAt ??= completedAt ?? Date.now();
381
- if (this._status !== "stopped") {
382
- this._status = "completed";
383
- }
342
+ this.state.markCompleted(result, completedAt);
384
343
  }
385
344
 
386
345
  /**
@@ -388,11 +347,7 @@ export class Subagent {
388
347
  * Always sets result and completedAt (??=). Only changes status if not stopped.
389
348
  */
390
349
  markAborted(result: string, completedAt?: number): void {
391
- this._result = result;
392
- this._completedAt ??= completedAt ?? Date.now();
393
- if (this._status !== "stopped") {
394
- this._status = "aborted";
395
- }
350
+ this.state.markAborted(result, completedAt);
396
351
  }
397
352
 
398
353
  /**
@@ -400,11 +355,7 @@ export class Subagent {
400
355
  * Always sets result and completedAt (??=). Only changes status if not stopped.
401
356
  */
402
357
  markSteered(result: string, completedAt?: number): void {
403
- this._result = result;
404
- this._completedAt ??= completedAt ?? Date.now();
405
- if (this._status !== "stopped") {
406
- this._status = "steered";
407
- }
358
+ this.state.markSteered(result, completedAt);
408
359
  }
409
360
 
410
361
  /**
@@ -412,17 +363,12 @@ export class Subagent {
412
363
  * Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
413
364
  */
414
365
  markError(error: unknown, completedAt?: number): void {
415
- this._error = error instanceof Error ? error.message : String(error);
416
- this._completedAt ??= completedAt ?? Date.now();
417
- if (this._status !== "stopped") {
418
- this._status = "error";
419
- }
366
+ this.state.markError(error, completedAt);
420
367
  }
421
368
 
422
369
  /** Transition to stopped state. Always valid — no guard. */
423
370
  markStopped(completedAt?: number): void {
424
- this._status = "stopped";
425
- this._completedAt = completedAt ?? Date.now();
371
+ this.state.markStopped(completedAt);
426
372
  }
427
373
 
428
374
  /**
@@ -432,7 +378,7 @@ export class Subagent {
432
378
  * then no-ops on the queued-status guard.
433
379
  */
434
380
  abort(): boolean {
435
- if (this._status !== "running") return false;
381
+ if (this.status !== "running") return false;
436
382
  this.abortController.abort();
437
383
  this.markStopped();
438
384
  return true;
@@ -459,11 +405,7 @@ export class Subagent {
459
405
 
460
406
  /** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */
461
407
  resetForResume(startedAt: number): void {
462
- this._status = "running";
463
- this._startedAt = startedAt;
464
- this._completedAt = undefined;
465
- this._result = undefined;
466
- this._error = undefined;
408
+ this.state.resetForResume(startedAt);
467
409
  this.releaseListeners();
468
410
  }
469
411
 
@@ -511,7 +453,7 @@ export class Subagent {
511
453
  else if (result.steered) this.markSteered(finalResult);
512
454
  else this.markCompleted(finalResult);
513
455
 
514
- this.observer?.onRunFinished?.(this);
456
+ this.execution.observer?.onRunFinished?.(this);
515
457
  }
516
458
 
517
459
  /** Dispose the wrapped session, firing the `disposed` lifecycle event. */
@@ -528,6 +470,6 @@ export class Subagent {
528
470
  if (this._workspace) this._workspace.dispose({ status: "error", description: this.description });
529
471
  } catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
530
472
 
531
- this.observer?.onRunFinished?.(this);
473
+ this.execution.observer?.onRunFinished?.(this);
532
474
  }
533
475
  }
@@ -1,40 +1,42 @@
1
1
  /**
2
- * record-observer.ts — Subscribes to session events and updates Subagent stats.
2
+ * record-observer.ts — Subscribes to session events and accumulates SubagentState stats.
3
3
  *
4
4
  * Replaces the scattered callback-wrapping logic in SubagentManager's startAgent()
5
- * and resume() with a single direct subscription.
5
+ * and resume() with a single direct subscription. The observer targets the
6
+ * SubagentState value object directly, so it carries no dependency on Subagent;
7
+ * the caller forwards itself to its own lifecycle observer via onCompact.
6
8
  */
7
9
 
8
- import type { Subagent } from "#src/lifecycle/subagent";
10
+ import type { SubagentState } from "#src/lifecycle/subagent-state";
9
11
  import type { CompactionInfo, SubscribableSession } from "#src/types";
10
12
 
11
13
  export interface SubagentObserverOptions {
12
- onCompact?: (record: Subagent, info: CompactionInfo) => void;
14
+ onCompact?: (info: CompactionInfo) => void;
13
15
  }
14
16
 
15
17
  /**
16
- * Subscribe to session events and accumulate stats on the subagent record.
18
+ * Subscribe to session events and accumulate stats on the subagent state.
17
19
  *
18
20
  * Handles:
19
- * - `tool_execution_end` → `record.incrementToolUses()`
20
- * - `message_end` (assistant, with usage) → `record.addUsage(…)`
21
- * - `compaction_end` (not aborted) → `record.incrementCompactions()`, call `onCompact`
21
+ * - `tool_execution_end` → `state.incrementToolUses()`
22
+ * - `message_end` (assistant, with usage) → `state.addUsage(…)`
23
+ * - `compaction_end` (not aborted) → `state.incrementCompactions()`, call `onCompact`
22
24
  *
23
25
  * @returns An unsubscribe function.
24
26
  */
25
27
  export function subscribeSubagentObserver(
26
28
  session: SubscribableSession,
27
- record: Subagent,
29
+ state: SubagentState,
28
30
  options?: SubagentObserverOptions,
29
31
  ): () => void {
30
32
  return session.subscribe((event) => {
31
33
  if (event.type === "tool_execution_end") {
32
- record.incrementToolUses();
34
+ state.incrementToolUses();
33
35
  }
34
36
 
35
37
  if (event.type === "message_end" && event.message.role === "assistant") {
36
38
  const u = event.message.usage;
37
- record.addUsage({
39
+ state.addUsage({
38
40
  input: u.input,
39
41
  output: u.output,
40
42
  cacheWrite: u.cacheWrite,
@@ -42,8 +44,8 @@ export function subscribeSubagentObserver(
42
44
  }
43
45
 
44
46
  if (event.type === "compaction_end" && !event.aborted && event.result) {
45
- record.incrementCompactions();
46
- options?.onCompact?.(record, {
47
+ state.incrementCompactions();
48
+ options?.onCompact?.({
47
49
  reason: event.reason,
48
50
  tokensBefore: event.result.tokensBefore,
49
51
  });