@yefengr/pi-subagents 0.0.0-stage → 0.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 +2411 -0
- package/LICENSE +21 -0
- package/README.md +374 -2
- package/UPSTREAM.md +48 -0
- package/dist/public.d.ts +201 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +833 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +79 -0
- package/docs/configuration.md +215 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/index.ts +1 -0
- package/package.json +91 -4
- package/src/config/agent-enablement.ts +132 -0
- package/src/config/agent-types.ts +144 -0
- package/src/config/custom-agents.ts +114 -0
- package/src/config/default-agents.ts +120 -0
- package/src/config/invocation-config.ts +39 -0
- package/src/config/resource-limits.ts +26 -0
- package/src/config/settings-file.ts +174 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +65 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +257 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +89 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +123 -0
- package/src/lifecycle/create-subagent-session.ts +265 -0
- package/src/lifecycle/invocation-control.ts +109 -0
- package/src/lifecycle/lifecycle-record.ts +159 -0
- package/src/lifecycle/parent-snapshot.ts +46 -0
- package/src/lifecycle/run-listeners.ts +41 -0
- package/src/lifecycle/subagent-manager.ts +392 -0
- package/src/lifecycle/subagent-session.ts +287 -0
- package/src/lifecycle/subagent-state.ts +307 -0
- package/src/lifecycle/subagent.ts +854 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +53 -0
- package/src/observation/notification.ts +233 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +106 -0
- package/src/observation/subagent-events-observer.ts +75 -0
- package/src/runtime.ts +78 -0
- package/src/service/service-adapter.ts +159 -0
- package/src/service/service.ts +147 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +135 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +145 -0
- package/src/session/provider-inheritance.ts +38 -0
- package/src/session/session-config.ts +177 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +436 -0
- package/src/tools/agent-tool.ts +289 -0
- package/src/tools/background-spawner.ts +73 -0
- package/src/tools/foreground-runner.ts +135 -0
- package/src/tools/get-result-report.ts +73 -0
- package/src/tools/get-result-tool.ts +109 -0
- package/src/tools/helpers.ts +134 -0
- package/src/tools/result-renderer.ts +121 -0
- package/src/tools/spawn-config.ts +182 -0
- package/src/tools/steer-tool.ts +120 -0
- package/src/types.ts +145 -0
- package/src/ui/agent-widget.ts +313 -0
- package/src/ui/display.ts +214 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +152 -0
- package/src/ui/session-navigator.ts +257 -0
- package/src/ui/subagents-agents.ts +116 -0
- package/src/ui/subagents-settings.ts +207 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +289 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-manager.ts - Tracks subagents, background execution, resume support.
|
|
3
|
+
*
|
|
4
|
+
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
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
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
11
|
+
import { debugLog } from "#src/debug";
|
|
12
|
+
import type { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
13
|
+
import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
|
|
14
|
+
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
15
|
+
import {
|
|
16
|
+
type InvocationLimitOverrides,
|
|
17
|
+
Subagent,
|
|
18
|
+
type SubagentLifecycleObserver,
|
|
19
|
+
} from "#src/lifecycle/subagent";
|
|
20
|
+
import type { SubagentSession } from "#src/lifecycle/subagent-session";
|
|
21
|
+
import { SubagentState } from "#src/lifecycle/subagent-state";
|
|
22
|
+
import type { WorkspaceProvider } from "#src/lifecycle/workspace";
|
|
23
|
+
|
|
24
|
+
import type { RunConfig } from "#src/runtime";
|
|
25
|
+
import type {
|
|
26
|
+
AgentInvocation,
|
|
27
|
+
CompactionInfo,
|
|
28
|
+
ParentSessionInfo,
|
|
29
|
+
SubagentInvocationIdentity,
|
|
30
|
+
SubagentType,
|
|
31
|
+
ThinkingLevel,
|
|
32
|
+
} from "#src/types";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Session-retention windows (minutes). `SettingsManager` satisfies this
|
|
36
|
+
* structurally; a live getter (`getRetentionPolicy`) lets the sweep read the
|
|
37
|
+
* current values without a construction-time settings dependency.
|
|
38
|
+
*/
|
|
39
|
+
export interface RetentionPolicy {
|
|
40
|
+
readonly consumedSessionRetentionMinutes: number;
|
|
41
|
+
readonly unconsumedSessionRetentionMinutes: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_RETENTION_POLICY: RetentionPolicy = {
|
|
45
|
+
consumedSessionRetentionMinutes: 10,
|
|
46
|
+
unconsumedSessionRetentionMinutes: 720,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Observer interface for agent lifecycle notifications. */
|
|
50
|
+
export interface SubagentManagerObserver {
|
|
51
|
+
onSubagentStarted(record: Subagent): void;
|
|
52
|
+
onSubagentCompleted(record: Subagent): void;
|
|
53
|
+
/** Fires when a resumed run reaches a terminal state (distinct from a fresh completion). */
|
|
54
|
+
onSubagentResumed(record: Subagent): void;
|
|
55
|
+
onSubagentCompacted(record: Subagent, info: CompactionInfo): void;
|
|
56
|
+
/** Fires synchronously after a background agent record is created (before run). */
|
|
57
|
+
onSubagentCreated(record: Subagent): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SubagentManagerOptions {
|
|
61
|
+
/** Assembly factory that produces a born-complete SubagentSession per spawn. */
|
|
62
|
+
createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
63
|
+
/** Concurrency limiter — schedules background run thunks FIFO against the limit. */
|
|
64
|
+
limiter: ConcurrencyLimiter;
|
|
65
|
+
/** Base working directory handed to a workspace provider (the parent cwd). */
|
|
66
|
+
baseCwd: string;
|
|
67
|
+
getRunConfig?: () => RunConfig;
|
|
68
|
+
/** Live accessor for the session-retention windows; defaults applied when absent. */
|
|
69
|
+
getRetentionPolicy?: () => RetentionPolicy;
|
|
70
|
+
observer?: SubagentManagerObserver;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AgentSpawnConfig {
|
|
74
|
+
description: string;
|
|
75
|
+
model?: Model<any>;
|
|
76
|
+
maxTurns?: number;
|
|
77
|
+
wrapUpTurns?: number;
|
|
78
|
+
maxDurationSeconds?: number;
|
|
79
|
+
inheritContext?: boolean;
|
|
80
|
+
thinkingLevel?: ThinkingLevel;
|
|
81
|
+
isBackground?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Skip the maxConcurrent queue check for this spawn - start immediately even
|
|
84
|
+
* if the configured concurrency limit would otherwise queue it. Useful for
|
|
85
|
+
* callers (e.g. cross-extension RPC) that must not be deferred by the queue.
|
|
86
|
+
*/
|
|
87
|
+
bypassQueue?: boolean;
|
|
88
|
+
/** Resolved invocation snapshot captured for UI display. */
|
|
89
|
+
invocation?: AgentInvocation;
|
|
90
|
+
/** Lifecycle identity for the fresh parent invocation. */
|
|
91
|
+
lifecycleInvocation?: SubagentInvocationIdentity;
|
|
92
|
+
/** Parent abort signal - when aborted, the subagent is also stopped. */
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
/** Per-subagent lifecycle observer — replaces onSessionCreated callback. */
|
|
95
|
+
observer?: SubagentLifecycleObserver;
|
|
96
|
+
/** Parent session identity - grouped fields that travel together from the tool boundary. */
|
|
97
|
+
parentSession?: ParentSessionInfo;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class SubagentManager {
|
|
101
|
+
private agents = new Map<string, Subagent>();
|
|
102
|
+
private sweepInterval: ReturnType<typeof setInterval>;
|
|
103
|
+
private readonly observer?: SubagentManagerObserver;
|
|
104
|
+
private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
105
|
+
private readonly limiter: ConcurrencyLimiter;
|
|
106
|
+
private readonly baseCwd: string;
|
|
107
|
+
private getRunConfig?: () => RunConfig;
|
|
108
|
+
private getRetentionPolicy?: () => RetentionPolicy;
|
|
109
|
+
private _workspaceProvider?: WorkspaceProvider;
|
|
110
|
+
|
|
111
|
+
/** The registered workspace provider, or undefined when none is registered. */
|
|
112
|
+
get workspaceProvider(): WorkspaceProvider | undefined {
|
|
113
|
+
return this._workspaceProvider;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
constructor(options: SubagentManagerOptions) {
|
|
117
|
+
this.createSubagentSession = options.createSubagentSession;
|
|
118
|
+
this.limiter = options.limiter;
|
|
119
|
+
this.baseCwd = options.baseCwd;
|
|
120
|
+
this.observer = options.observer;
|
|
121
|
+
this.getRunConfig = options.getRunConfig;
|
|
122
|
+
this.getRetentionPolicy = options.getRetentionPolicy;
|
|
123
|
+
// Periodically release the heavy session of terminal agents past their
|
|
124
|
+
// retention window. The lightweight record (with its result) is kept for the
|
|
125
|
+
// session lifetime, so get_subagent_result never misses in-session.
|
|
126
|
+
this.sweepInterval = setInterval(() => this.sweep(), 60_000);
|
|
127
|
+
this.sweepInterval.unref();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Register the single workspace provider. Throws if one is already
|
|
132
|
+
* registered (chaining is out of scope — see ADR 0002). Returns a disposer
|
|
133
|
+
* that clears the slot only if this provider is still the active one.
|
|
134
|
+
*/
|
|
135
|
+
registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
|
|
136
|
+
if (this._workspaceProvider) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"A WorkspaceProvider is already registered; only one is supported.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
this._workspaceProvider = provider;
|
|
142
|
+
return () => {
|
|
143
|
+
if (this._workspaceProvider === provider) this._workspaceProvider = undefined;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
|
|
148
|
+
private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver {
|
|
149
|
+
return {
|
|
150
|
+
onStarted: (agent) => {
|
|
151
|
+
this.observer?.onSubagentStarted(agent);
|
|
152
|
+
},
|
|
153
|
+
onSessionCreated: options.observer?.onSessionCreated
|
|
154
|
+
? (agent) => options.observer!.onSessionCreated!(agent)
|
|
155
|
+
: undefined,
|
|
156
|
+
onRunFinished: (agent) => {
|
|
157
|
+
try { this.observer?.onSubagentCompleted(agent); } catch (err) { debugLog("onSubagentCompleted observer", err); }
|
|
158
|
+
},
|
|
159
|
+
onResumeFinished: (agent) => {
|
|
160
|
+
try { this.observer?.onSubagentResumed(agent); } catch (err) { debugLog("onSubagentResumed observer", err); }
|
|
161
|
+
},
|
|
162
|
+
onCompacted: (agent, info) => {
|
|
163
|
+
this.observer?.onSubagentCompacted(agent, info);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Spawn an agent and return its ID immediately (for background use).
|
|
170
|
+
* If the concurrency limit is reached, the agent is queued.
|
|
171
|
+
*/
|
|
172
|
+
spawn(
|
|
173
|
+
snapshot: ParentSnapshot,
|
|
174
|
+
type: SubagentType,
|
|
175
|
+
prompt: string,
|
|
176
|
+
options: AgentSpawnConfig,
|
|
177
|
+
): string {
|
|
178
|
+
const id = randomUUID().slice(0, 17);
|
|
179
|
+
const record = new Subagent({
|
|
180
|
+
id,
|
|
181
|
+
type,
|
|
182
|
+
description: options.description,
|
|
183
|
+
invocation: options.invocation,
|
|
184
|
+
isBackground: options.isBackground ?? false,
|
|
185
|
+
lifecycleInvocation: options.lifecycleInvocation,
|
|
186
|
+
state: new SubagentState({
|
|
187
|
+
phase: options.isBackground ? "queued" : "running",
|
|
188
|
+
startedAt: Date.now(),
|
|
189
|
+
}),
|
|
190
|
+
execution: {
|
|
191
|
+
createSubagentSession: this.createSubagentSession,
|
|
192
|
+
snapshot,
|
|
193
|
+
prompt,
|
|
194
|
+
baseCwd: this.baseCwd,
|
|
195
|
+
observer: this.buildObserver(options),
|
|
196
|
+
getRunConfig: this.getRunConfig,
|
|
197
|
+
getWorkspaceProvider: () => this._workspaceProvider,
|
|
198
|
+
model: options.model,
|
|
199
|
+
maxTurns: options.maxTurns,
|
|
200
|
+
wrapUpTurns: options.wrapUpTurns,
|
|
201
|
+
maxDurationSeconds: options.maxDurationSeconds,
|
|
202
|
+
thinkingLevel: options.thinkingLevel,
|
|
203
|
+
parentSession: options.parentSession,
|
|
204
|
+
signal: options.signal,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
this.agents.set(id, record);
|
|
208
|
+
|
|
209
|
+
if (options.isBackground) {
|
|
210
|
+
this.observer?.onSubagentCreated(record);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (options.isBackground && !options.bypassQueue) {
|
|
214
|
+
// Schedule on the limiter — scheduleVia captures the limiter promise
|
|
215
|
+
// eagerly, so a queued agent is awaitable from spawn; guardedRun guards
|
|
216
|
+
// against abort-while-queued when the slot frees.
|
|
217
|
+
const timeoutMs = (this.getRunConfig?.().maxQueueWaitSeconds ?? 900) * 1000;
|
|
218
|
+
record.scheduleVia((thunk) => this.limiter.schedule(thunk, {
|
|
219
|
+
timeoutMs,
|
|
220
|
+
onTimeout: () => record.queueTimedOut(timeoutMs),
|
|
221
|
+
}));
|
|
222
|
+
return id;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
record.start();
|
|
226
|
+
return id;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Spawn an agent and wait for completion (foreground use).
|
|
231
|
+
* Foreground agents bypass the concurrency queue.
|
|
232
|
+
*/
|
|
233
|
+
async spawnAndWait(
|
|
234
|
+
snapshot: ParentSnapshot,
|
|
235
|
+
type: SubagentType,
|
|
236
|
+
prompt: string,
|
|
237
|
+
options: Omit<AgentSpawnConfig, "isBackground">,
|
|
238
|
+
): Promise<Subagent> {
|
|
239
|
+
const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false });
|
|
240
|
+
const record = this.agents.get(id)!;
|
|
241
|
+
await record.promise;
|
|
242
|
+
return record;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Resume an existing agent session with a new prompt.
|
|
247
|
+
* Delegates to Subagent.resume(), which owns the observer subscription lifecycle.
|
|
248
|
+
*/
|
|
249
|
+
async resume(
|
|
250
|
+
id: string,
|
|
251
|
+
prompt: string,
|
|
252
|
+
invocation: SubagentInvocationIdentity,
|
|
253
|
+
limits?: InvocationLimitOverrides,
|
|
254
|
+
signal?: AbortSignal,
|
|
255
|
+
): Promise<Subagent | undefined> {
|
|
256
|
+
const agent = this.agents.get(id);
|
|
257
|
+
if (!agent?.isSessionReady()) return undefined;
|
|
258
|
+
await agent.resume(prompt, invocation, limits, signal);
|
|
259
|
+
return agent;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getRecord(id: string): Subagent | undefined {
|
|
263
|
+
return this.agents.get(id);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
listAgents(): Subagent[] {
|
|
267
|
+
return [...this.agents.values()].sort(
|
|
268
|
+
(a, b) => b.startedAt - a.startedAt,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
abort(id: string): boolean {
|
|
273
|
+
const record = this.agents.get(id);
|
|
274
|
+
if (!record) return false;
|
|
275
|
+
|
|
276
|
+
// A queued agent has not started; stop it through the same terminal funnel
|
|
277
|
+
// a running agent's stop uses. Its scheduled thunk becomes a no-op (phase
|
|
278
|
+
// guard) when its slot finally opens.
|
|
279
|
+
if (record.phase === "queued") {
|
|
280
|
+
record.stopQueued();
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return record.abort();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Remove a record from the map and tear its session down.
|
|
289
|
+
* The map is updated first so the record is unreachable while its child's
|
|
290
|
+
* extensions shut down.
|
|
291
|
+
*/
|
|
292
|
+
private removeRecord(id: string, record: Subagent): Promise<void> {
|
|
293
|
+
this.agents.delete(id);
|
|
294
|
+
return record.disposeSession();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Release the heavy session of any terminal agent past its retention window.
|
|
299
|
+
* The record (with its result) is retained for the session lifetime; only the
|
|
300
|
+
* live `AgentSession` is freed. A consumed agent releases on the short window,
|
|
301
|
+
* measured from the later of completion or consumption (so a late read still
|
|
302
|
+
* gets a full resume window); an unconsumed agent holds until the long cap.
|
|
303
|
+
*/
|
|
304
|
+
private sweep() {
|
|
305
|
+
const policy = this.getRetentionPolicy?.() ?? DEFAULT_RETENTION_POLICY;
|
|
306
|
+
const now = Date.now();
|
|
307
|
+
for (const record of this.agents.values()) {
|
|
308
|
+
if (record.isActive()) continue;
|
|
309
|
+
if (!record.isSessionReady()) continue; // already released, or never had a session
|
|
310
|
+
const referenceAt = record.consumed
|
|
311
|
+
? Math.max(record.completedAt ?? 0, record.consumedAt ?? 0)
|
|
312
|
+
: record.completedAt ?? 0;
|
|
313
|
+
const windowMinutes = record.consumed
|
|
314
|
+
? policy.consumedSessionRetentionMinutes
|
|
315
|
+
: policy.unconsumedSessionRetentionMinutes;
|
|
316
|
+
// Fire-and-forget: the sweep runs on an interval with no one to await it,
|
|
317
|
+
// and Subagent.releaseSession() already swallows a failing teardown.
|
|
318
|
+
if (now - referenceAt >= windowMinutes * 60_000) void record.releaseSession();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Remove all completed/stopped/errored records immediately.
|
|
324
|
+
* Called on session start/switch so tasks from a prior session don't persist.
|
|
325
|
+
*/
|
|
326
|
+
async clearCompleted(): Promise<void> {
|
|
327
|
+
const teardowns: Promise<void>[] = [];
|
|
328
|
+
for (const [id, record] of this.agents) {
|
|
329
|
+
if (record.isActive()) continue;
|
|
330
|
+
teardowns.push(this.removeRecord(id, record));
|
|
331
|
+
}
|
|
332
|
+
await Promise.all(teardowns);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Whether any agents are still running or queued. */
|
|
336
|
+
// fallow-ignore-next-line unused-class-member
|
|
337
|
+
hasRunning(): boolean {
|
|
338
|
+
return [...this.agents.values()].some(r => r.isActive());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Abort all running and queued agents immediately. */
|
|
342
|
+
abortAll(): number {
|
|
343
|
+
let count = 0;
|
|
344
|
+
for (const record of this.agents.values()) {
|
|
345
|
+
if (record.phase === "queued") {
|
|
346
|
+
record.stopQueued();
|
|
347
|
+
count++;
|
|
348
|
+
} else if (record.abort()) {
|
|
349
|
+
count++;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Drop pending thunks (their promises resolve).
|
|
353
|
+
this.limiter.clear();
|
|
354
|
+
return count;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
358
|
+
// fallow-ignore-next-line unused-class-member
|
|
359
|
+
async waitForAll(): Promise<void> {
|
|
360
|
+
// Every spawned agent has a settled-on-completion promise (the limiter starts
|
|
361
|
+
// queued ones as slots free), so a single allSettled covers the queued case.
|
|
362
|
+
// The loop only catches agents spawned during the wait.
|
|
363
|
+
let pending = this.pendingPromises();
|
|
364
|
+
while (pending.length > 0) {
|
|
365
|
+
await Promise.allSettled(pending);
|
|
366
|
+
pending = this.pendingPromises();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Promises of all running/queued agents that have one. */
|
|
371
|
+
private pendingPromises(): Promise<void>[] {
|
|
372
|
+
return [...this.agents.values()]
|
|
373
|
+
.filter(r => r.isActive())
|
|
374
|
+
.map(r => r.promise)
|
|
375
|
+
.filter((p): p is Promise<void> => p != null);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Tear down every record, resolving once each child's extensions have shut
|
|
380
|
+
* down. The registry is emptied before the teardowns are awaited, so nothing
|
|
381
|
+
* can reach a dying record; `allSettled` keeps one failing child from
|
|
382
|
+
* abandoning its siblings.
|
|
383
|
+
*/
|
|
384
|
+
async dispose(): Promise<void> {
|
|
385
|
+
clearInterval(this.sweepInterval);
|
|
386
|
+
// Drop pending thunks
|
|
387
|
+
this.limiter.clear();
|
|
388
|
+
const teardowns = [...this.agents.values()].map(record => record.disposeSession());
|
|
389
|
+
this.agents.clear();
|
|
390
|
+
await Promise.allSettled(teardowns);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-session.ts — The born-complete child-session value object (issue #265).
|
|
3
|
+
*
|
|
4
|
+
* A SubagentSession wraps one SDK AgentSession plus its turn-driving and teardown.
|
|
5
|
+
* It is born complete: `createSubagentSession()` returns a fully usable instance
|
|
6
|
+
* (session created, extensions bound, recursion guard applied), so the only thing
|
|
7
|
+
* left for `Subagent` to do is coordinate — drive the turn loop, steer, dispose.
|
|
8
|
+
*
|
|
9
|
+
* Turn driving lives here, on the object that owns the AgentSession, rather than
|
|
10
|
+
* reaching through `subagentSession.session` from `Subagent` (Law of Demeter).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type AgentSession,
|
|
15
|
+
type AgentSessionEvent,
|
|
16
|
+
type ToolDefinition,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
|
|
19
|
+
import { emitChildSessionShutdown } from "#src/lifecycle/child-shutdown";
|
|
20
|
+
import { isInvocationTimeout } from "#src/lifecycle/invocation-control";
|
|
21
|
+
import { getSessionContextPercent, type SessionStatsLike } from "#src/lifecycle/usage";
|
|
22
|
+
import { extractText } from "#src/session/context";
|
|
23
|
+
import { getAgentConversation } from "#src/session/conversation";
|
|
24
|
+
import type { SessionMessage } from "#src/types";
|
|
25
|
+
|
|
26
|
+
export type TurnLoopOutcome = "completed" | "turn_limit" | "timeout" | "cancelled";
|
|
27
|
+
|
|
28
|
+
/** Outcome of one invocation's model turn loop. */
|
|
29
|
+
export interface TurnLoopResult {
|
|
30
|
+
responseText: string;
|
|
31
|
+
outcome: TurnLoopOutcome;
|
|
32
|
+
/** Whether the agent received the configured wrap-up instruction. */
|
|
33
|
+
wrappedUp: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Strict limits for one fresh or resumed invocation. */
|
|
37
|
+
export interface TurnLoopOptions {
|
|
38
|
+
maxTurns: number;
|
|
39
|
+
wrapUpTurns: number;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Session-level facts known at creation, supplied by the factory. */
|
|
44
|
+
export interface SubagentSessionMeta {
|
|
45
|
+
/** Path to the persisted session JSONL file, if the session was persisted. */
|
|
46
|
+
outputFile: string | undefined;
|
|
47
|
+
/** Child session id — the registry key carried on session-created/disposed events. */
|
|
48
|
+
sessionId: string;
|
|
49
|
+
/** Child session directory — carried on the completed event as transcript location. */
|
|
50
|
+
sessionDir: string;
|
|
51
|
+
agentName: string;
|
|
52
|
+
/** Parent context prepended to the run prompt, captured at spawn time. */
|
|
53
|
+
parentContext: string | undefined;
|
|
54
|
+
lifecycle: ChildLifecyclePublisher;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One child AgentSession plus its turn-driving and teardown — born complete.
|
|
59
|
+
*/
|
|
60
|
+
export class SubagentSession {
|
|
61
|
+
private disposed = false;
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
private readonly _session: AgentSession,
|
|
65
|
+
private readonly meta: SubagentSessionMeta,
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wrapped session — for lifecycle-internal use only.
|
|
70
|
+
* @internal consumers outside lifecycle/ use the delegate methods below.
|
|
71
|
+
*/
|
|
72
|
+
get session(): AgentSession {
|
|
73
|
+
return this._session;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Stable Pi session identifier for transcript attribution. */
|
|
77
|
+
get sessionId(): string {
|
|
78
|
+
return this.meta.sessionId;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get outputFile(): string | undefined {
|
|
82
|
+
return this.meta.outputFile;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Drive the initial invocation and publish its child-session outcome. */
|
|
86
|
+
async runTurnLoop(prompt: string, opts: TurnLoopOptions): Promise<TurnLoopResult> {
|
|
87
|
+
const effectivePrompt = this.meta.parentContext
|
|
88
|
+
? this.meta.parentContext + prompt
|
|
89
|
+
: prompt;
|
|
90
|
+
const result = await this.executePrompt(effectivePrompt, opts);
|
|
91
|
+
this.meta.lifecycle.completed({
|
|
92
|
+
sessionDir: this.meta.sessionDir,
|
|
93
|
+
agentName: this.meta.agentName,
|
|
94
|
+
outcome: result.outcome,
|
|
95
|
+
wrappedUp: result.wrappedUp,
|
|
96
|
+
});
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Re-prompt the same session with a fresh invocation budget. */
|
|
101
|
+
async resumeTurnLoop(prompt: string, opts: TurnLoopOptions): Promise<TurnLoopResult> {
|
|
102
|
+
return this.executePrompt(prompt, opts);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async executePrompt(prompt: string, opts: TurnLoopOptions): Promise<TurnLoopResult> {
|
|
106
|
+
const session = this._session;
|
|
107
|
+
let turnCount = 0;
|
|
108
|
+
let wrappedUp = false;
|
|
109
|
+
let hardLimitRequiredAbort = false;
|
|
110
|
+
const wrapUpAt = opts.maxTurns - Math.min(opts.wrapUpTurns, opts.maxTurns - 1);
|
|
111
|
+
|
|
112
|
+
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
|
|
113
|
+
if (event.type !== "turn_end") return;
|
|
114
|
+
|
|
115
|
+
turnCount++;
|
|
116
|
+
const assistantContinues = turnNeedsContinuation(event);
|
|
117
|
+
if (
|
|
118
|
+
!wrappedUp &&
|
|
119
|
+
opts.wrapUpTurns > 0 &&
|
|
120
|
+
turnCount >= wrapUpAt &&
|
|
121
|
+
turnCount < opts.maxTurns &&
|
|
122
|
+
assistantContinues
|
|
123
|
+
) {
|
|
124
|
+
wrappedUp = true;
|
|
125
|
+
void session.steer(
|
|
126
|
+
"You are approaching your hard turn limit. Stop exploring and provide your final answer now.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (turnCount >= opts.maxTurns && assistantContinues) {
|
|
131
|
+
hardLimitRequiredAbort = true;
|
|
132
|
+
void session.abort();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const collector = collectResponseText(session);
|
|
137
|
+
const cleanupAbort = forwardAbortSignal(session, opts.signal);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await session.prompt(prompt);
|
|
141
|
+
} finally {
|
|
142
|
+
unsubTurns();
|
|
143
|
+
collector.unsubscribe();
|
|
144
|
+
cleanupAbort();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const responseText = collector.getText().trim() || getLastAssistantText(session);
|
|
148
|
+
const outcome = resolveTurnLoopOutcome(opts.signal, hardLimitRequiredAbort);
|
|
149
|
+
return { responseText, outcome, wrappedUp };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Deliver a steer to the live session. */
|
|
153
|
+
async steer(message: string): Promise<void> {
|
|
154
|
+
await this._session.steer(message);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Abort the current SDK operation. */
|
|
158
|
+
async abort(): Promise<void> {
|
|
159
|
+
await this._session.abort();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Return the session's conversation as formatted text. */
|
|
163
|
+
getConversation(): string {
|
|
164
|
+
return getAgentConversation(this._session);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Return the session context window utilization (0-100), or null when unavailable. */
|
|
168
|
+
getContextPercent(): number | null {
|
|
169
|
+
return getSessionContextPercent(this._session);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Subscribe to session events. Satisfies `SubscribableSession`. */
|
|
173
|
+
subscribe(fn: (event: AgentSessionEvent) => void): () => void {
|
|
174
|
+
return this._session.subscribe(fn);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Return session token statistics. Satisfies `SessionLike`. */
|
|
178
|
+
getSessionStats(): SessionStatsLike {
|
|
179
|
+
return this._session.getSessionStats();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The session's message history. */
|
|
183
|
+
get messages(): readonly unknown[] {
|
|
184
|
+
return this._session.messages as readonly unknown[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The session's message history, typed for Pi's session-rendering machinery. */
|
|
188
|
+
get agentMessages(): readonly SessionMessage[] {
|
|
189
|
+
return this._session.messages;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Resolve a registered tool definition by name, for Pi's tool-execution components. */
|
|
193
|
+
getToolDefinition(name: string): ToolDefinition | undefined {
|
|
194
|
+
return this._session.getToolDefinition(name);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Tear down: child `session_shutdown` + session.dispose() + emit `disposed`
|
|
199
|
+
* (registry unregister).
|
|
200
|
+
*
|
|
201
|
+
* The order is load-bearing. The shutdown emit runs first because
|
|
202
|
+
* `AgentSession.dispose()` invalidates the extension runner, after which every
|
|
203
|
+
* handler's context throws (#709). The `disposed` event runs last because it
|
|
204
|
+
* unregisters the child from the permission system, so a shutdown handler
|
|
205
|
+
* still resolves against a live registration.
|
|
206
|
+
*
|
|
207
|
+
* Idempotent: the guard is set before the first await, so concurrent callers
|
|
208
|
+
* (a retention sweep racing a manager teardown) emit and dispose exactly once.
|
|
209
|
+
*/
|
|
210
|
+
async dispose(): Promise<void> {
|
|
211
|
+
if (this.disposed) return;
|
|
212
|
+
this.disposed = true;
|
|
213
|
+
await emitChildSessionShutdown(this._session);
|
|
214
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
|
|
215
|
+
this._session.dispose?.();
|
|
216
|
+
this.meta.lifecycle.disposed({ sessionId: this.meta.sessionId });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Private turn-loop helpers ───────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Subscribe to a session and collect the last assistant message text.
|
|
224
|
+
* Returns an object with a `getText()` getter and an `unsubscribe` function.
|
|
225
|
+
*/
|
|
226
|
+
function collectResponseText(session: AgentSession) {
|
|
227
|
+
let text = "";
|
|
228
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
229
|
+
if (event.type === "message_start") {
|
|
230
|
+
text = "";
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
event.type === "message_update" &&
|
|
234
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
235
|
+
) {
|
|
236
|
+
text += event.assistantMessageEvent.delta;
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
return { getText: () => text, unsubscribe };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Get the last assistant text from the completed session history. */
|
|
243
|
+
function getLastAssistantText(session: AgentSession): string {
|
|
244
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
245
|
+
const msg = session.messages[i];
|
|
246
|
+
if (msg.role !== "assistant") continue;
|
|
247
|
+
const text = extractText(msg.content).trim();
|
|
248
|
+
if (text) return text;
|
|
249
|
+
}
|
|
250
|
+
return "";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Wire an AbortSignal to abort a session.
|
|
255
|
+
* Returns a cleanup function to remove the listener.
|
|
256
|
+
*/
|
|
257
|
+
function forwardAbortSignal(
|
|
258
|
+
session: AgentSession,
|
|
259
|
+
signal?: AbortSignal,
|
|
260
|
+
): () => void {
|
|
261
|
+
if (!signal) return () => {};
|
|
262
|
+
const onAbort = (): void => {
|
|
263
|
+
void session.abort();
|
|
264
|
+
};
|
|
265
|
+
if (signal.aborted) {
|
|
266
|
+
onAbort();
|
|
267
|
+
return () => {};
|
|
268
|
+
}
|
|
269
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
270
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function turnNeedsContinuation(event: Extract<AgentSessionEvent, { type: "turn_end" }>): boolean {
|
|
274
|
+
if (event.message.role !== "assistant") return false;
|
|
275
|
+
if (event.message.stopReason === "length") return true;
|
|
276
|
+
return event.message.content.some((content) => content.type === "toolCall");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function resolveTurnLoopOutcome(
|
|
280
|
+
signal: AbortSignal | undefined,
|
|
281
|
+
hardLimitRequiredAbort: boolean,
|
|
282
|
+
): TurnLoopOutcome {
|
|
283
|
+
if (signal?.aborted) {
|
|
284
|
+
return isInvocationTimeout(signal.reason) ? "timeout" : "cancelled";
|
|
285
|
+
}
|
|
286
|
+
return hardLimitRequiredAbort ? "turn_limit" : "completed";
|
|
287
|
+
}
|