@jopqior/pi-subagents 1.0.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 +2705 -0
- package/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/public.d.ts +331 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +1566 -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 +77 -0
- package/docs/configuration.md +364 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
- package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
- package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
- package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
- package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
- package/package.json +91 -0
- package/src/config/agent-types.ts +135 -0
- package/src/config/custom-agents.ts +151 -0
- package/src/config/default-agents.ts +121 -0
- package/src/config/invocation-config.ts +167 -0
- package/src/config/thinking-level.ts +58 -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 +71 -0
- package/src/handlers/widget-events.ts +49 -0
- package/src/index.ts +292 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +115 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +335 -0
- package/src/lifecycle/parent-snapshot.ts +119 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/selection-scope.ts +116 -0
- package/src/lifecycle/spawn-selection.ts +259 -0
- package/src/lifecycle/subagent-manager.ts +546 -0
- package/src/lifecycle/subagent-session.ts +347 -0
- package/src/lifecycle/subagent-state.ts +404 -0
- package/src/lifecycle/subagent.ts +885 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +76 -0
- package/src/lifecycle/workspace.ts +46 -0
- package/src/observation/composite-subagent-observer.ts +74 -0
- package/src/observation/notification.ts +430 -0
- package/src/observation/outcome-delivery.ts +239 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +161 -0
- package/src/observation/subagent-events-observer.ts +148 -0
- package/src/runtime.ts +137 -0
- package/src/service/service-adapter.ts +201 -0
- package/src/service/service.ts +246 -0
- package/src/session/ask-parent-tool.ts +69 -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 +126 -0
- package/src/session/notify-parent-tool.ts +83 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +231 -0
- package/src/session/provider-inheritance.ts +56 -0
- package/src/session/selection-catalogue.ts +143 -0
- package/src/session/session-config.ts +202 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +447 -0
- package/src/tools/agent-tool.ts +305 -0
- package/src/tools/background-spawner.ts +83 -0
- package/src/tools/foreground-runner.ts +159 -0
- package/src/tools/get-result-renderer.ts +119 -0
- package/src/tools/get-result-report.ts +84 -0
- package/src/tools/get-result-tool.ts +192 -0
- package/src/tools/helpers.ts +118 -0
- package/src/tools/result-renderer.ts +153 -0
- package/src/tools/spawn-config.ts +192 -0
- package/src/tools/steer-tool.ts +109 -0
- package/src/types.ts +143 -0
- package/src/ui/agent-widget.ts +333 -0
- package/src/ui/bounded-lines.ts +45 -0
- package/src/ui/display.ts +180 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +150 -0
- package/src/ui/session-navigator.ts +255 -0
- package/src/ui/subagents-settings.ts +179 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +301 -0
|
@@ -0,0 +1,546 @@
|
|
|
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 { type BackgroundRequest, resolveBackgroundMode } from "#src/config/invocation-config";
|
|
12
|
+
import { debugLog } from "#src/debug";
|
|
13
|
+
import type { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
14
|
+
import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
|
|
15
|
+
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
16
|
+
import type { SelectionScopeHandle } from "#src/lifecycle/selection-scope";
|
|
17
|
+
import { type ResumeRefusal, Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent";
|
|
18
|
+
import type { SubagentSession } from "#src/lifecycle/subagent-session";
|
|
19
|
+
import { SubagentState } from "#src/lifecycle/subagent-state";
|
|
20
|
+
import type { WorkspaceProvider } from "#src/lifecycle/workspace";
|
|
21
|
+
|
|
22
|
+
import type { RunConfig } from "#src/runtime";
|
|
23
|
+
import type { AgentConfig, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The agent-registry slice the manager needs to resolve a spawn. Deliberately
|
|
27
|
+
* narrower than AgentConfigLookup, whose slice serves session assembly (ISP).
|
|
28
|
+
*/
|
|
29
|
+
export interface SpawnTypeResolver {
|
|
30
|
+
resolveType(name: string): string | undefined;
|
|
31
|
+
isValidType(type: string): boolean;
|
|
32
|
+
resolveAgentConfig(type: string): AgentConfig;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Why a resume was refused, across every front door.
|
|
37
|
+
*
|
|
38
|
+
* Widens the record's own vocabulary by the one refusal that is not a fact
|
|
39
|
+
* about a record: an id no record answers to.
|
|
40
|
+
*/
|
|
41
|
+
export type ResumeRefusalReason = ResumeRefusal | "unknown-agent";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a resume attempt produced: the record whose run was restarted, or the
|
|
45
|
+
* reason nothing was started.
|
|
46
|
+
*
|
|
47
|
+
* A resumed run that *failed* is still `resumed` — the record carries the
|
|
48
|
+
* error. `refused` means the turn loop never ran.
|
|
49
|
+
*/
|
|
50
|
+
export type ResumeOutcome =
|
|
51
|
+
| { kind: "resumed"; record: Subagent }
|
|
52
|
+
| { kind: "refused"; reason: ResumeRefusalReason };
|
|
53
|
+
|
|
54
|
+
/** Per-call knobs for a resume; both doors pass their own. */
|
|
55
|
+
export interface ResumeCallOptions {
|
|
56
|
+
/** Cancels the resumed turn loop. A resume does not run under the record's own controller. */
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
/**
|
|
59
|
+
* The caller will deliver this outcome to the parent, so nothing announces
|
|
60
|
+
* it. Omitted, the resumed outcome is announced like any other completion.
|
|
61
|
+
*/
|
|
62
|
+
claimOutcome?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A spawn's resolved identity and mode — the invariants every front door shares. */
|
|
66
|
+
interface ResolvedSpawn {
|
|
67
|
+
type: SubagentType;
|
|
68
|
+
isBackground: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Session-retention windows (minutes). `SettingsManager` satisfies this
|
|
73
|
+
* structurally; a live getter (`getRetentionPolicy`) lets the sweep read the
|
|
74
|
+
* current values without a construction-time settings dependency.
|
|
75
|
+
*/
|
|
76
|
+
export interface RetentionPolicy {
|
|
77
|
+
readonly consumedSessionRetentionMinutes: number;
|
|
78
|
+
readonly unconsumedSessionRetentionMinutes: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_RETENTION_POLICY: RetentionPolicy = {
|
|
82
|
+
consumedSessionRetentionMinutes: 10,
|
|
83
|
+
unconsumedSessionRetentionMinutes: 720,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Only what the retention rule reads. Narrower than `Subagent` so the rule
|
|
88
|
+
* stays a plain function over four facts, testable without spawning an agent.
|
|
89
|
+
*/
|
|
90
|
+
export interface RetentionCandidate {
|
|
91
|
+
consumed: boolean;
|
|
92
|
+
completedAt: number | undefined;
|
|
93
|
+
consumedAt: number | undefined;
|
|
94
|
+
pendingQuestion: string | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** When a terminal record's session-release window opened, and how long it runs. */
|
|
98
|
+
export interface RetentionWindow {
|
|
99
|
+
referenceAt: number;
|
|
100
|
+
windowMinutes: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Pick the retention window for one terminal record.
|
|
105
|
+
*
|
|
106
|
+
* A collected outcome releases on the short window, measured from the later of
|
|
107
|
+
* completion and collection, so a late read still gets a full resume window; an
|
|
108
|
+
* uncollected one holds until the long safety cap.
|
|
109
|
+
*
|
|
110
|
+
* A record carrying an unanswered question is not collected, whatever
|
|
111
|
+
* `consumed` says: the parent has read the question but has not answered it,
|
|
112
|
+
* and the answer is delivered by resuming the very session the short window
|
|
113
|
+
* would release.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveRetentionWindow(
|
|
116
|
+
record: RetentionCandidate,
|
|
117
|
+
policy: RetentionPolicy,
|
|
118
|
+
): RetentionWindow {
|
|
119
|
+
if (record.consumed && record.pendingQuestion === undefined) {
|
|
120
|
+
return {
|
|
121
|
+
referenceAt: Math.max(record.completedAt ?? 0, record.consumedAt ?? 0),
|
|
122
|
+
windowMinutes: policy.consumedSessionRetentionMinutes,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
referenceAt: record.completedAt ?? 0,
|
|
127
|
+
windowMinutes: policy.unconsumedSessionRetentionMinutes,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Observer interface for agent lifecycle notifications. */
|
|
132
|
+
export interface SubagentManagerObserver {
|
|
133
|
+
onSubagentStarted(record: Subagent): void;
|
|
134
|
+
onSubagentCompleted(record: Subagent): void;
|
|
135
|
+
/**
|
|
136
|
+
* Fires when a resume starts, from whichever front door asked for it.
|
|
137
|
+
* Required: a consumer that tracks the widget's live set has to learn that a
|
|
138
|
+
* settled record went back to running, and the only alternative is polling.
|
|
139
|
+
*/
|
|
140
|
+
onSubagentResuming(record: Subagent): void;
|
|
141
|
+
/** Fires when a resumed run reaches a terminal state (distinct from a fresh completion). */
|
|
142
|
+
onSubagentResumed(record: Subagent): void;
|
|
143
|
+
/**
|
|
144
|
+
* Fires when a running child sends its parent a mid-run message.
|
|
145
|
+
* Optional: the widget has no use for it, and a hook nobody supplies is a
|
|
146
|
+
* vacant one.
|
|
147
|
+
*/
|
|
148
|
+
onSubagentUpdate?(record: Subagent, message: string): void;
|
|
149
|
+
/**
|
|
150
|
+
* Fires when a teardown after the record's result was delivered reported
|
|
151
|
+
* where its work went.
|
|
152
|
+
* Optional for the same reason as `onSubagentUpdate`: the widget has no use
|
|
153
|
+
* for it, and a hook nobody supplies is a vacant one.
|
|
154
|
+
*/
|
|
155
|
+
onSubagentWorkspaceNotice?(record: Subagent, notice: string): void;
|
|
156
|
+
onSubagentCompacted(record: Subagent, info: CompactionInfo): void;
|
|
157
|
+
/** Fires synchronously after a background agent record is created (before run). */
|
|
158
|
+
onSubagentCreated(record: Subagent): void;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface SubagentManagerOptions {
|
|
162
|
+
/** Assembly factory that produces a born-complete SubagentSession per spawn. */
|
|
163
|
+
createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
164
|
+
/** Concurrency limiter — schedules background run thunks FIFO against the limit. */
|
|
165
|
+
limiter: ConcurrencyLimiter;
|
|
166
|
+
/** Base working directory handed to a workspace provider (the parent cwd). */
|
|
167
|
+
baseCwd: string;
|
|
168
|
+
getRunConfig?: () => RunConfig;
|
|
169
|
+
/** Live accessor for the session-retention windows; defaults applied when absent. */
|
|
170
|
+
getRetentionPolicy?: () => RetentionPolicy;
|
|
171
|
+
observer?: SubagentManagerObserver;
|
|
172
|
+
/** Agent registry, consulted to canonicalize a spawn's type and resolve its config. */
|
|
173
|
+
registry: SpawnTypeResolver;
|
|
174
|
+
/**
|
|
175
|
+
* The spawning session's retained selection scope, threaded into every
|
|
176
|
+
* record — queued records included — so an admitted run consults the tree's
|
|
177
|
+
* active provider before creating its child session.
|
|
178
|
+
*/
|
|
179
|
+
selectionScope: SelectionScopeHandle;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface AgentSpawnConfig {
|
|
183
|
+
description: string;
|
|
184
|
+
model?: Model<any>;
|
|
185
|
+
maxTurns?: number;
|
|
186
|
+
inheritContext?: boolean;
|
|
187
|
+
thinkingLevel?: ThinkingLevel;
|
|
188
|
+
/**
|
|
189
|
+
* Whether this door has committed to a background mode or is offering a
|
|
190
|
+
* default the agent's frontmatter may override. Required so a new front door
|
|
191
|
+
* cannot silently inherit another's policy.
|
|
192
|
+
*/
|
|
193
|
+
background: BackgroundRequest;
|
|
194
|
+
/**
|
|
195
|
+
* Skip the maxConcurrent queue check for this spawn - start immediately even
|
|
196
|
+
* if the configured concurrency limit would otherwise queue it. Useful for
|
|
197
|
+
* callers (e.g. cross-extension RPC) that must not be deferred by the queue.
|
|
198
|
+
*/
|
|
199
|
+
bypassQueue?: boolean;
|
|
200
|
+
/** Parent abort signal - when aborted, the subagent is also stopped. */
|
|
201
|
+
signal?: AbortSignal;
|
|
202
|
+
/** Per-subagent lifecycle observer — replaces onSessionCreated callback. */
|
|
203
|
+
observer?: SubagentLifecycleObserver;
|
|
204
|
+
/** Parent session identity - grouped fields that travel together from the tool boundary. */
|
|
205
|
+
parentSession?: ParentSessionInfo;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export class SubagentManager {
|
|
209
|
+
private agents = new Map<string, Subagent>();
|
|
210
|
+
private sweepInterval: ReturnType<typeof setInterval>;
|
|
211
|
+
private readonly observer?: SubagentManagerObserver;
|
|
212
|
+
private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
213
|
+
private readonly limiter: ConcurrencyLimiter;
|
|
214
|
+
private readonly baseCwd: string;
|
|
215
|
+
private getRunConfig?: () => RunConfig;
|
|
216
|
+
private getRetentionPolicy?: () => RetentionPolicy;
|
|
217
|
+
private readonly registry: SpawnTypeResolver;
|
|
218
|
+
private readonly selectionScope: SelectionScopeHandle;
|
|
219
|
+
private _workspaceProvider?: WorkspaceProvider;
|
|
220
|
+
|
|
221
|
+
/** The registered workspace provider, or undefined when none is registered. */
|
|
222
|
+
get workspaceProvider(): WorkspaceProvider | undefined {
|
|
223
|
+
return this._workspaceProvider;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
constructor(options: SubagentManagerOptions) {
|
|
227
|
+
this.createSubagentSession = options.createSubagentSession;
|
|
228
|
+
this.limiter = options.limiter;
|
|
229
|
+
this.baseCwd = options.baseCwd;
|
|
230
|
+
this.observer = options.observer;
|
|
231
|
+
this.getRunConfig = options.getRunConfig;
|
|
232
|
+
this.getRetentionPolicy = options.getRetentionPolicy;
|
|
233
|
+
this.registry = options.registry;
|
|
234
|
+
this.selectionScope = options.selectionScope;
|
|
235
|
+
// Periodically release the heavy session of terminal agents past their
|
|
236
|
+
// retention window. The lightweight record (with its result) is kept for the
|
|
237
|
+
// session lifetime, so get_subagent_result never misses in-session.
|
|
238
|
+
this.sweepInterval = setInterval(() => this.sweep(), 60_000);
|
|
239
|
+
this.sweepInterval.unref();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Register the single workspace provider. Throws if one is already
|
|
244
|
+
* registered (chaining is out of scope — see ADR 0002). Returns a disposer
|
|
245
|
+
* that clears the slot only if this provider is still the active one.
|
|
246
|
+
*/
|
|
247
|
+
registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
|
|
248
|
+
if (this._workspaceProvider) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
"A WorkspaceProvider is already registered; only one is supported.",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
this._workspaceProvider = provider;
|
|
254
|
+
return () => {
|
|
255
|
+
if (this._workspaceProvider === provider) this._workspaceProvider = undefined;
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
|
|
260
|
+
private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver {
|
|
261
|
+
return {
|
|
262
|
+
onStarted: (agent) => {
|
|
263
|
+
this.observer?.onSubagentStarted(agent);
|
|
264
|
+
},
|
|
265
|
+
onSessionCreated: options.observer?.onSessionCreated
|
|
266
|
+
? (agent) => options.observer!.onSessionCreated!(agent)
|
|
267
|
+
: undefined,
|
|
268
|
+
// Terminal transitions are reported for every agent. Whether the parent
|
|
269
|
+
// needs telling is the notification layer's decision, made from the
|
|
270
|
+
// carrier claim; suppressing the observer here would also suppress the
|
|
271
|
+
// lifecycle event and the session-history record, which are facts about
|
|
272
|
+
// the run rather than announcements.
|
|
273
|
+
onRunFinished: (agent) => {
|
|
274
|
+
try { this.observer?.onSubagentCompleted(agent); } catch (err) { debugLog("onSubagentCompleted observer", err); }
|
|
275
|
+
},
|
|
276
|
+
onResumeStarted: (agent) => {
|
|
277
|
+
try { this.observer?.onSubagentResuming(agent); } catch (err) { debugLog("onSubagentResuming observer", err); }
|
|
278
|
+
},
|
|
279
|
+
onResumeFinished: (agent) => {
|
|
280
|
+
try { this.observer?.onSubagentResumed(agent); } catch (err) { debugLog("onSubagentResumed observer", err); }
|
|
281
|
+
},
|
|
282
|
+
onUpdateSent: (agent, message) => {
|
|
283
|
+
this.observer?.onSubagentUpdate?.(agent, message);
|
|
284
|
+
},
|
|
285
|
+
onWorkspaceNotice: (agent, notice) => {
|
|
286
|
+
this.observer?.onSubagentWorkspaceNotice?.(agent, notice);
|
|
287
|
+
},
|
|
288
|
+
onCompacted: (agent, info) => {
|
|
289
|
+
this.observer?.onSubagentCompacted(agent, info);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Spawn an agent and return its ID immediately (for background use).
|
|
296
|
+
* If the concurrency limit is reached, the agent is queued.
|
|
297
|
+
*
|
|
298
|
+
* Throws when the named agent type is disabled.
|
|
299
|
+
*/
|
|
300
|
+
spawn(
|
|
301
|
+
snapshot: ParentSnapshot,
|
|
302
|
+
type: SubagentType,
|
|
303
|
+
prompt: string,
|
|
304
|
+
options: AgentSpawnConfig,
|
|
305
|
+
): string {
|
|
306
|
+
return this.create(snapshot, this.resolveSpawn(type, options.background), prompt, options);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Spawn an agent and wait for completion (foreground use).
|
|
311
|
+
* Foreground agents bypass the concurrency queue.
|
|
312
|
+
*
|
|
313
|
+
* The caller holds the result, which is a delivery commitment: the agent must
|
|
314
|
+
* not be queued and must not be announced, whatever its frontmatter declares.
|
|
315
|
+
*
|
|
316
|
+
* Rejects when the named agent type is disabled.
|
|
317
|
+
*/
|
|
318
|
+
async spawnAndWait(
|
|
319
|
+
snapshot: ParentSnapshot,
|
|
320
|
+
type: SubagentType,
|
|
321
|
+
prompt: string,
|
|
322
|
+
options: Omit<AgentSpawnConfig, "background">,
|
|
323
|
+
): Promise<Subagent> {
|
|
324
|
+
const foreground: BackgroundRequest = { kind: "explicit", isBackground: false };
|
|
325
|
+
const id = this.create(snapshot, this.resolveSpawn(type, foreground), prompt, {
|
|
326
|
+
...options,
|
|
327
|
+
background: foreground,
|
|
328
|
+
});
|
|
329
|
+
const record = this.agents.get(id)!;
|
|
330
|
+
// The caller holds the result, so this call is the carrier: claim the outcome
|
|
331
|
+
// before awaiting it, so nothing announces what is already being delivered.
|
|
332
|
+
record.claim();
|
|
333
|
+
await record.promise;
|
|
334
|
+
return record;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Stamp the invariants every front door shares: a canonical agent type, a
|
|
339
|
+
* rejection for a disabled one, and the effective background mode.
|
|
340
|
+
*/
|
|
341
|
+
private resolveSpawn(type: string, background: BackgroundRequest): ResolvedSpawn {
|
|
342
|
+
const canonical = this.registry.resolveType(type);
|
|
343
|
+
if (canonical !== undefined && !this.registry.isValidType(canonical)) {
|
|
344
|
+
throw new Error(`Agent type "${canonical}" is disabled`);
|
|
345
|
+
}
|
|
346
|
+
const resolvedType = canonical ?? "general-purpose";
|
|
347
|
+
const agentConfig = this.registry.resolveAgentConfig(resolvedType);
|
|
348
|
+
return { type: resolvedType, isBackground: resolveBackgroundMode(agentConfig, background) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Create, register, and start (or queue) a record for an already-resolved spawn. */
|
|
352
|
+
private create(
|
|
353
|
+
snapshot: ParentSnapshot,
|
|
354
|
+
resolved: ResolvedSpawn,
|
|
355
|
+
prompt: string,
|
|
356
|
+
options: AgentSpawnConfig,
|
|
357
|
+
): string {
|
|
358
|
+
const { type, isBackground } = resolved;
|
|
359
|
+
const id = randomUUID().slice(0, 17);
|
|
360
|
+
const record = new Subagent({
|
|
361
|
+
id,
|
|
362
|
+
type,
|
|
363
|
+
description: options.description,
|
|
364
|
+
isBackground,
|
|
365
|
+
state: new SubagentState({
|
|
366
|
+
status: isBackground ? "queued" : "running",
|
|
367
|
+
startedAt: Date.now(),
|
|
368
|
+
}),
|
|
369
|
+
execution: {
|
|
370
|
+
createSubagentSession: this.createSubagentSession,
|
|
371
|
+
snapshot,
|
|
372
|
+
prompt,
|
|
373
|
+
baseCwd: this.baseCwd,
|
|
374
|
+
observer: this.buildObserver(options),
|
|
375
|
+
getRunConfig: this.getRunConfig,
|
|
376
|
+
getWorkspaceProvider: () => this._workspaceProvider,
|
|
377
|
+
selectionScope: this.selectionScope,
|
|
378
|
+
model: options.model,
|
|
379
|
+
maxTurns: options.maxTurns,
|
|
380
|
+
thinkingLevel: options.thinkingLevel,
|
|
381
|
+
parentSession: options.parentSession,
|
|
382
|
+
signal: options.signal,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
this.agents.set(id, record);
|
|
386
|
+
|
|
387
|
+
if (isBackground) {
|
|
388
|
+
this.observer?.onSubagentCreated(record);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (isBackground && !options.bypassQueue) {
|
|
392
|
+
// Schedule on the limiter — scheduleVia captures the limiter promise
|
|
393
|
+
// eagerly, so a queued agent is awaitable from spawn; guardedRun guards
|
|
394
|
+
// against abort-while-queued when the slot frees.
|
|
395
|
+
record.scheduleVia((thunk) => this.limiter.schedule(thunk));
|
|
396
|
+
return id;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
record.start();
|
|
400
|
+
return id;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Resume an existing agent session with a new prompt.
|
|
405
|
+
*
|
|
406
|
+
* The refusal policy lives here rather than in a caller, so every front door
|
|
407
|
+
* declines the same resumes for the same reasons; a door owns only how it
|
|
408
|
+
* words the answer. Delegates to Subagent.resume(), which owns the observer
|
|
409
|
+
* subscription lifecycle.
|
|
410
|
+
*/
|
|
411
|
+
async resume(id: string, prompt: string, options: ResumeCallOptions = {}): Promise<ResumeOutcome> {
|
|
412
|
+
const agent = this.agents.get(id);
|
|
413
|
+
if (!agent) return { kind: "refused", reason: "unknown-agent" };
|
|
414
|
+
const refusal = agent.resumeRefusal;
|
|
415
|
+
if (refusal) return { kind: "refused", reason: refusal };
|
|
416
|
+
// Before the resume starts: resetForResume runs synchronously inside
|
|
417
|
+
// resume(), so a claim taken afterwards would miss the terminal edge.
|
|
418
|
+
if (options.claimOutcome) agent.claim();
|
|
419
|
+
await agent.resume(prompt, options.signal);
|
|
420
|
+
return { kind: "resumed", record: agent };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
getRecord(id: string): Subagent | undefined {
|
|
424
|
+
return this.agents.get(id);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
listAgents(): Subagent[] {
|
|
428
|
+
return [...this.agents.values()].sort(
|
|
429
|
+
(a, b) => b.startedAt - a.startedAt,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
abort(id: string): boolean {
|
|
434
|
+
const record = this.agents.get(id);
|
|
435
|
+
if (!record) return false;
|
|
436
|
+
|
|
437
|
+
// A queued agent has not started; stop it through the same terminal funnel
|
|
438
|
+
// a running agent's stop uses. Its scheduled thunk becomes a no-op (status
|
|
439
|
+
// guard) when its slot finally opens.
|
|
440
|
+
if (record.status === "queued") {
|
|
441
|
+
record.stopQueued();
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return record.abort();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Remove a record from the map and tear its session down.
|
|
450
|
+
* The map is updated first so the record is unreachable while its child's
|
|
451
|
+
* extensions shut down.
|
|
452
|
+
*/
|
|
453
|
+
private removeRecord(id: string, record: Subagent): Promise<void> {
|
|
454
|
+
this.agents.delete(id);
|
|
455
|
+
return record.disposeSession();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Release the heavy session of any terminal agent past its retention window.
|
|
460
|
+
* The record (with its result) is retained for the session lifetime; only the
|
|
461
|
+
* live `AgentSession` is freed. `resolveRetentionWindow` owns which window
|
|
462
|
+
* applies.
|
|
463
|
+
*/
|
|
464
|
+
private sweep() {
|
|
465
|
+
const policy = this.getRetentionPolicy?.() ?? DEFAULT_RETENTION_POLICY;
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
for (const record of this.agents.values()) {
|
|
468
|
+
if (record.isActive()) continue;
|
|
469
|
+
if (!record.isSessionReady()) continue; // already released, or never had a session
|
|
470
|
+
const { referenceAt, windowMinutes } = resolveRetentionWindow(record, policy);
|
|
471
|
+
// Fire-and-forget: the sweep runs on an interval with no one to await it,
|
|
472
|
+
// and Subagent.releaseSession() already swallows a failing teardown.
|
|
473
|
+
if (now - referenceAt >= windowMinutes * 60_000) void record.releaseSession();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Remove all completed/stopped/errored records immediately.
|
|
479
|
+
* Called on session start/switch so tasks from a prior session don't persist.
|
|
480
|
+
*/
|
|
481
|
+
async clearCompleted(): Promise<void> {
|
|
482
|
+
const teardowns: Promise<void>[] = [];
|
|
483
|
+
for (const [id, record] of this.agents) {
|
|
484
|
+
if (record.isActive()) continue;
|
|
485
|
+
teardowns.push(this.removeRecord(id, record));
|
|
486
|
+
}
|
|
487
|
+
await Promise.all(teardowns);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Whether any agents are still running or queued. */
|
|
491
|
+
// fallow-ignore-next-line unused-class-member
|
|
492
|
+
hasRunning(): boolean {
|
|
493
|
+
return [...this.agents.values()].some(r => r.isActive());
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Abort all running and queued agents immediately. */
|
|
497
|
+
abortAll(): number {
|
|
498
|
+
let count = 0;
|
|
499
|
+
for (const record of this.agents.values()) {
|
|
500
|
+
if (record.status === "queued") {
|
|
501
|
+
record.stopQueued();
|
|
502
|
+
count++;
|
|
503
|
+
} else if (record.abort()) {
|
|
504
|
+
count++;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Drop pending thunks (their promises resolve).
|
|
508
|
+
this.limiter.clear();
|
|
509
|
+
return count;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
513
|
+
async waitForAll(): Promise<void> {
|
|
514
|
+
// Every spawned agent has a settled-on-completion promise (the limiter starts
|
|
515
|
+
// queued ones as slots free), so a single allSettled covers the queued case.
|
|
516
|
+
// The loop only catches agents spawned during the wait.
|
|
517
|
+
let pending = this.pendingPromises();
|
|
518
|
+
while (pending.length > 0) {
|
|
519
|
+
await Promise.allSettled(pending);
|
|
520
|
+
pending = this.pendingPromises();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Promises of all running/queued agents that have one. */
|
|
525
|
+
private pendingPromises(): Promise<void>[] {
|
|
526
|
+
return [...this.agents.values()]
|
|
527
|
+
.filter(r => r.isActive())
|
|
528
|
+
.map(r => r.promise)
|
|
529
|
+
.filter((p): p is Promise<void> => p != null);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Tear down every record, resolving once each child's extensions have shut
|
|
534
|
+
* down. The registry is emptied before the teardowns are awaited, so nothing
|
|
535
|
+
* can reach a dying record; `allSettled` keeps one failing child from
|
|
536
|
+
* abandoning its siblings.
|
|
537
|
+
*/
|
|
538
|
+
async dispose(): Promise<void> {
|
|
539
|
+
clearInterval(this.sweepInterval);
|
|
540
|
+
// Drop pending thunks
|
|
541
|
+
this.limiter.clear();
|
|
542
|
+
const teardowns = [...this.agents.values()].map(record => record.disposeSession());
|
|
543
|
+
this.agents.clear();
|
|
544
|
+
await Promise.allSettled(teardowns);
|
|
545
|
+
}
|
|
546
|
+
}
|