@xl0/pi-lovely-agents 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 +17 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/extensions/lovely-agents/agent.ts +1374 -0
- package/extensions/lovely-agents/bash.ts +599 -0
- package/extensions/lovely-agents/child-session.ts +296 -0
- package/extensions/lovely-agents/config.ts +221 -0
- package/extensions/lovely-agents/coordinator.ts +506 -0
- package/extensions/lovely-agents/definitions.ts +380 -0
- package/extensions/lovely-agents/index.ts +400 -0
- package/extensions/lovely-agents/lifecycle.ts +251 -0
- package/extensions/lovely-agents/management.ts +638 -0
- package/extensions/lovely-agents/notifications.ts +220 -0
- package/extensions/lovely-agents/provider-limits.ts +13 -0
- package/extensions/lovely-agents/rendering.ts +90 -0
- package/extensions/lovely-agents/state.ts +1179 -0
- package/extensions/lovely-agents/task-panel.ts +192 -0
- package/extensions/lovely-agents/tools.ts +635 -0
- package/extensions/lovely-agents/updates.ts +45 -0
- package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
- package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
- package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
- package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
- package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
- package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
- package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
- package/package.json +68 -0
- package/skills/agent/SKILL.md +21 -0
- package/skills/agent-creator/SKILL.md +35 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
2
|
+
import type { TaskMetadata } from "./state.js"
|
|
3
|
+
import { publishSchedulerUpdate } from "./updates.js"
|
|
4
|
+
|
|
5
|
+
export const AGENT_COORDINATOR_VERSION = 3
|
|
6
|
+
const AGENT_COORDINATOR_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/coordinator")
|
|
7
|
+
const BASH_COORDINATOR_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/bash-coordinator")
|
|
8
|
+
|
|
9
|
+
export type ModelTuple = Readonly<{ provider: string; model: string }>
|
|
10
|
+
|
|
11
|
+
export type AgentScheduleRequest = {
|
|
12
|
+
tuple: ModelTuple
|
|
13
|
+
acceptanceOrder?: number
|
|
14
|
+
signal?: AbortSignal
|
|
15
|
+
/** Foreground callers cannot leave work parked behind a provider gate. */
|
|
16
|
+
rejectOnClosedTuple?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type AgentPermit = {
|
|
20
|
+
readonly held: boolean
|
|
21
|
+
lend<T>(wait: () => Promise<T>, signal?: AbortSignal): Promise<T>
|
|
22
|
+
release(): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AgentReservation = {
|
|
26
|
+
readonly acceptanceOrder: number
|
|
27
|
+
activate(): void
|
|
28
|
+
run<T>(work: () => Promise<T>): Promise<T>
|
|
29
|
+
cancel(reason?: unknown): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ResidentAgent = {
|
|
33
|
+
stop(): void | Promise<void>
|
|
34
|
+
dispose(): void | Promise<void>
|
|
35
|
+
input?(content: string, delivery: "followup" | "steer" | "stdin", options?: ResidentInputOptions): Promise<ResidentInputResult>
|
|
36
|
+
recover?(): boolean | Promise<boolean>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ResidentInputOptions = { background?: boolean; signal?: AbortSignal; eof?: boolean }
|
|
40
|
+
|
|
41
|
+
export type ResidentInputResult = {
|
|
42
|
+
delivery: "followup" | "steer" | "stdin"
|
|
43
|
+
queuePosition: number | null
|
|
44
|
+
queuedFollowUps: number
|
|
45
|
+
completed?: TaskMetadata
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ParentNotification = Readonly<{ id: string; taskRef: string; content: string }>
|
|
49
|
+
export type ParentNotificationRoute = (notification: ParentNotification) => void | Promise<void>
|
|
50
|
+
export type ManagedSessionContext = Readonly<{
|
|
51
|
+
depth: number
|
|
52
|
+
allowAgents: boolean
|
|
53
|
+
/** Aborted before SDK disposal, which does not emit session_shutdown. */
|
|
54
|
+
disposeSignal?: AbortSignal
|
|
55
|
+
}>
|
|
56
|
+
|
|
57
|
+
export type AgentCoordinator = {
|
|
58
|
+
readonly version: typeof AGENT_COORDINATOR_VERSION
|
|
59
|
+
readonly maxConcurrency: number
|
|
60
|
+
readonly activeCount: number
|
|
61
|
+
readonly queuedCount: number
|
|
62
|
+
readonly residentCount: number
|
|
63
|
+
nextAcceptanceOrder(): number
|
|
64
|
+
setMaxConcurrency(limit: number): void
|
|
65
|
+
closeTuple(tuple: ModelTuple): void
|
|
66
|
+
openTuple(tuple: ModelTuple): void
|
|
67
|
+
isTupleOpen(tuple: ModelTuple): boolean
|
|
68
|
+
acquire(request: AgentScheduleRequest): Promise<AgentPermit>
|
|
69
|
+
run<T>(request: AgentScheduleRequest, work: () => Promise<T>): Promise<T>
|
|
70
|
+
reserve(request: AgentScheduleRequest): AgentReservation
|
|
71
|
+
withLentPermit<T>(wait: () => Promise<T>, signal?: AbortSignal): Promise<T>
|
|
72
|
+
bindResident(taskKey: string, resident: ResidentAgent): () => void
|
|
73
|
+
getResident(taskKey: string): ResidentAgent | undefined
|
|
74
|
+
bindNotificationRoute(parentKey: string, route: ParentNotificationRoute): () => void
|
|
75
|
+
getNotificationRoute(parentKey: string): ParentNotificationRoute | undefined
|
|
76
|
+
bindSessionContext(sessionId: string, context: ManagedSessionContext): () => void
|
|
77
|
+
getSessionContext(sessionId: string): ManagedSessionContext | undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type Waiter = {
|
|
81
|
+
tuple: string
|
|
82
|
+
acceptanceOrder: number
|
|
83
|
+
queueOrder: number
|
|
84
|
+
bypassTupleGate: boolean
|
|
85
|
+
eligible: boolean
|
|
86
|
+
resolve: () => void
|
|
87
|
+
reject: (error: unknown) => void
|
|
88
|
+
signal?: AbortSignal
|
|
89
|
+
onAbort?: () => void
|
|
90
|
+
rejectOnClosedTuple: boolean
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class ProcessAgentCoordinator implements AgentCoordinator {
|
|
94
|
+
readonly version = AGENT_COORDINATOR_VERSION
|
|
95
|
+
readonly #permits = new AsyncLocalStorage<Permit>()
|
|
96
|
+
readonly #closedTuples = new Set<string>()
|
|
97
|
+
readonly #waiters: Waiter[] = []
|
|
98
|
+
readonly #residents = new Map<string, ResidentAgent>()
|
|
99
|
+
readonly #notificationRoutes = new Map<string, ParentNotificationRoute>()
|
|
100
|
+
readonly #sessionContexts = new Map<string, ManagedSessionContext>()
|
|
101
|
+
#limit: number
|
|
102
|
+
#active = 0
|
|
103
|
+
#acceptanceOrder = 0
|
|
104
|
+
#queueOrder = 0
|
|
105
|
+
|
|
106
|
+
constructor(limit: number) {
|
|
107
|
+
assertConcurrency(limit)
|
|
108
|
+
this.#limit = limit
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get maxConcurrency(): number {
|
|
112
|
+
return this.#limit
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get activeCount(): number {
|
|
116
|
+
return this.#active
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get queuedCount(): number {
|
|
120
|
+
return this.#waiters.length
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get residentCount(): number {
|
|
124
|
+
return this.#residents.size
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
nextAcceptanceOrder(): number {
|
|
128
|
+
return ++this.#acceptanceOrder
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setMaxConcurrency(limit: number): void {
|
|
132
|
+
assertConcurrency(limit)
|
|
133
|
+
this.#limit = limit
|
|
134
|
+
this.#drain()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
closeTuple(tuple: ModelTuple): void {
|
|
138
|
+
assertTuple(tuple)
|
|
139
|
+
this.#closedTuples.add(tupleKey(tuple))
|
|
140
|
+
for (const waiter of [...this.#waiters]) {
|
|
141
|
+
if (waiter.tuple === tupleKey(tuple) && waiter.rejectOnClosedTuple) {
|
|
142
|
+
this.cancel(waiter, new Error("Provider/model is suspended by a provider limit; foreground work cannot wait for recovery"))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
publishSchedulerUpdate()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
openTuple(tuple: ModelTuple): void {
|
|
149
|
+
assertTuple(tuple)
|
|
150
|
+
this.#closedTuples.delete(tupleKey(tuple))
|
|
151
|
+
this.#drain()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
isTupleOpen(tuple: ModelTuple): boolean {
|
|
155
|
+
assertTuple(tuple)
|
|
156
|
+
return !this.#closedTuples.has(tupleKey(tuple))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async acquire(request: AgentScheduleRequest): Promise<AgentPermit> {
|
|
160
|
+
await this.#waitForSlot(request, false)
|
|
161
|
+
return new Permit(this, { provider: request.tuple.provider, model: request.tuple.model })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async run<T>(request: AgentScheduleRequest, work: () => Promise<T>): Promise<T> {
|
|
165
|
+
const permit = await this.acquire(request)
|
|
166
|
+
return this.#permits.run(permit as Permit, async () => {
|
|
167
|
+
try {
|
|
168
|
+
return await work()
|
|
169
|
+
} finally {
|
|
170
|
+
permit.release()
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
reserve(request: AgentScheduleRequest): AgentReservation {
|
|
176
|
+
assertTuple(request.tuple)
|
|
177
|
+
if (request.signal?.aborted) throw abortError(request.signal)
|
|
178
|
+
const acceptanceOrder = request.acceptanceOrder ?? this.nextAcceptanceOrder()
|
|
179
|
+
if (!Number.isSafeInteger(acceptanceOrder) || acceptanceOrder < 1) {
|
|
180
|
+
throw new Error("acceptanceOrder must be a positive safe integer")
|
|
181
|
+
}
|
|
182
|
+
this.#acceptanceOrder = Math.max(this.#acceptanceOrder, acceptanceOrder)
|
|
183
|
+
const queued = this.#enqueue(request, acceptanceOrder, false, false)
|
|
184
|
+
return new Reservation(this, request.tuple, acceptanceOrder, queued.waiter, queued.promise)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async withLentPermit<T>(wait: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
188
|
+
const permit = this.#permits.getStore()
|
|
189
|
+
return permit?.held ? permit.lend(wait, signal) : wait()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
bindResident(taskKey: string, resident: ResidentAgent): () => void {
|
|
193
|
+
assertRegistryKey(taskKey)
|
|
194
|
+
this.#residents.set(taskKey, resident)
|
|
195
|
+
return () => {
|
|
196
|
+
if (this.#residents.get(taskKey) === resident) this.#residents.delete(taskKey)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
getResident(taskKey: string): ResidentAgent | undefined {
|
|
201
|
+
assertRegistryKey(taskKey)
|
|
202
|
+
return this.#residents.get(taskKey)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
bindNotificationRoute(parentKey: string, route: ParentNotificationRoute): () => void {
|
|
206
|
+
assertRegistryKey(parentKey)
|
|
207
|
+
this.#notificationRoutes.set(parentKey, route)
|
|
208
|
+
return () => {
|
|
209
|
+
if (this.#notificationRoutes.get(parentKey) === route) this.#notificationRoutes.delete(parentKey)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
getNotificationRoute(parentKey: string): ParentNotificationRoute | undefined {
|
|
214
|
+
assertRegistryKey(parentKey)
|
|
215
|
+
return this.#notificationRoutes.get(parentKey)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
bindSessionContext(sessionId: string, context: ManagedSessionContext): () => void {
|
|
219
|
+
assertRegistryKey(sessionId)
|
|
220
|
+
if (!Number.isSafeInteger(context.depth) || context.depth < 0)
|
|
221
|
+
throw new Error("Managed session depth must be a nonnegative safe integer")
|
|
222
|
+
const stored = { ...context }
|
|
223
|
+
this.#sessionContexts.set(sessionId, stored)
|
|
224
|
+
return () => {
|
|
225
|
+
if (this.#sessionContexts.get(sessionId) === stored) this.#sessionContexts.delete(sessionId)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
getSessionContext(sessionId: string): ManagedSessionContext | undefined {
|
|
230
|
+
assertRegistryKey(sessionId)
|
|
231
|
+
return this.#sessionContexts.get(sessionId)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async reacquire(tuple: ModelTuple, signal?: AbortSignal): Promise<void> {
|
|
235
|
+
await this.#waitForSlot({ tuple, ...(signal ? { signal } : {}) }, true)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
releaseSlot(): void {
|
|
239
|
+
if (this.#active <= 0) throw new Error("Agent coordinator released an unowned permit")
|
|
240
|
+
this.#active--
|
|
241
|
+
this.#drain()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async #waitForSlot(request: AgentScheduleRequest, bypassTupleGate: boolean): Promise<void> {
|
|
245
|
+
assertTuple(request.tuple)
|
|
246
|
+
if (request.signal?.aborted) throw abortError(request.signal)
|
|
247
|
+
const acceptanceOrder = request.acceptanceOrder ?? this.nextAcceptanceOrder()
|
|
248
|
+
if (!Number.isSafeInteger(acceptanceOrder) || acceptanceOrder < 1) {
|
|
249
|
+
throw new Error("acceptanceOrder must be a positive safe integer")
|
|
250
|
+
}
|
|
251
|
+
this.#acceptanceOrder = Math.max(this.#acceptanceOrder, acceptanceOrder)
|
|
252
|
+
|
|
253
|
+
await this.#enqueue(request, acceptanceOrder, bypassTupleGate, true).promise
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#enqueue(
|
|
257
|
+
request: AgentScheduleRequest,
|
|
258
|
+
acceptanceOrder: number,
|
|
259
|
+
bypassTupleGate: boolean,
|
|
260
|
+
eligible: boolean
|
|
261
|
+
): { waiter: Waiter; promise: Promise<void> } {
|
|
262
|
+
if (request.rejectOnClosedTuple && !this.isTupleOpen(request.tuple)) {
|
|
263
|
+
throw new Error("Provider/model is suspended by a provider limit; foreground work cannot wait for recovery")
|
|
264
|
+
}
|
|
265
|
+
let waiter!: Waiter
|
|
266
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
267
|
+
waiter = {
|
|
268
|
+
tuple: tupleKey(request.tuple),
|
|
269
|
+
acceptanceOrder,
|
|
270
|
+
queueOrder: ++this.#queueOrder,
|
|
271
|
+
bypassTupleGate,
|
|
272
|
+
eligible,
|
|
273
|
+
rejectOnClosedTuple: request.rejectOnClosedTuple ?? false,
|
|
274
|
+
resolve,
|
|
275
|
+
reject,
|
|
276
|
+
...(request.signal ? { signal: request.signal } : {})
|
|
277
|
+
}
|
|
278
|
+
if (request.signal) {
|
|
279
|
+
waiter.onAbort = () => {
|
|
280
|
+
const index = this.#waiters.indexOf(waiter)
|
|
281
|
+
if (index < 0) return
|
|
282
|
+
this.#waiters.splice(index, 1)
|
|
283
|
+
reject(abortError(request.signal as AbortSignal))
|
|
284
|
+
}
|
|
285
|
+
request.signal.addEventListener("abort", waiter.onAbort, { once: true })
|
|
286
|
+
}
|
|
287
|
+
const insertion = this.#waiters.findIndex(
|
|
288
|
+
queued =>
|
|
289
|
+
queued.acceptanceOrder > waiter.acceptanceOrder ||
|
|
290
|
+
(queued.acceptanceOrder === waiter.acceptanceOrder && queued.queueOrder > waiter.queueOrder)
|
|
291
|
+
)
|
|
292
|
+
if (insertion < 0) this.#waiters.push(waiter)
|
|
293
|
+
else this.#waiters.splice(insertion, 0, waiter)
|
|
294
|
+
this.#drain()
|
|
295
|
+
})
|
|
296
|
+
return { waiter, promise }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
activate(waiter: Waiter): void {
|
|
300
|
+
if (!this.#waiters.includes(waiter)) return
|
|
301
|
+
waiter.eligible = true
|
|
302
|
+
this.#drain()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
cancel(waiter: Waiter, reason?: unknown): void {
|
|
306
|
+
const index = this.#waiters.indexOf(waiter)
|
|
307
|
+
if (index < 0) return
|
|
308
|
+
this.#waiters.splice(index, 1)
|
|
309
|
+
if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort)
|
|
310
|
+
waiter.reject(reason instanceof Error ? reason : new Error("Agent reservation cancelled"))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async runReservation<T>(tuple: ModelTuple, waiter: Waiter, ready: Promise<void>, work: () => Promise<T>): Promise<T> {
|
|
314
|
+
this.activate(waiter)
|
|
315
|
+
await ready
|
|
316
|
+
const permit = new Permit(this, tuple)
|
|
317
|
+
return this.#permits.run(permit, async () => {
|
|
318
|
+
try {
|
|
319
|
+
return await work()
|
|
320
|
+
} finally {
|
|
321
|
+
permit.release()
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#drain(): void {
|
|
327
|
+
while (this.#active < this.#limit) {
|
|
328
|
+
const index = this.#waiters.findIndex(waiter => waiter.eligible && (waiter.bypassTupleGate || !this.#closedTuples.has(waiter.tuple)))
|
|
329
|
+
if (index < 0) break
|
|
330
|
+
const [waiter] = this.#waiters.splice(index, 1)
|
|
331
|
+
if (!waiter) break
|
|
332
|
+
if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort)
|
|
333
|
+
this.#active++
|
|
334
|
+
waiter.resolve()
|
|
335
|
+
}
|
|
336
|
+
publishSchedulerUpdate()
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
class Reservation implements AgentReservation {
|
|
341
|
+
readonly acceptanceOrder: number
|
|
342
|
+
readonly #coordinator: ProcessAgentCoordinator
|
|
343
|
+
readonly #tuple: ModelTuple
|
|
344
|
+
readonly #waiter: Waiter
|
|
345
|
+
readonly #ready: Promise<void>
|
|
346
|
+
#used = false
|
|
347
|
+
|
|
348
|
+
constructor(coordinator: ProcessAgentCoordinator, tuple: ModelTuple, acceptanceOrder: number, waiter: Waiter, ready: Promise<void>) {
|
|
349
|
+
this.#coordinator = coordinator
|
|
350
|
+
this.#tuple = { ...tuple }
|
|
351
|
+
this.acceptanceOrder = acceptanceOrder
|
|
352
|
+
this.#waiter = waiter
|
|
353
|
+
this.#ready = ready
|
|
354
|
+
void this.#ready.catch(() => {})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
activate(): void {
|
|
358
|
+
this.#coordinator.activate(this.#waiter)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async run<T>(work: () => Promise<T>): Promise<T> {
|
|
362
|
+
if (this.#used) throw new Error("Agent reservation has already been used")
|
|
363
|
+
this.#used = true
|
|
364
|
+
return this.#coordinator.runReservation(this.#tuple, this.#waiter, this.#ready, work)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
cancel(reason?: unknown): void {
|
|
368
|
+
this.#coordinator.cancel(this.#waiter, reason)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
class Permit implements AgentPermit {
|
|
373
|
+
readonly #coordinator: ProcessAgentCoordinator
|
|
374
|
+
readonly #tuple: ModelTuple
|
|
375
|
+
#state: "held" | "lent" | "released" = "held"
|
|
376
|
+
#reacquireAbort: AbortController | undefined
|
|
377
|
+
|
|
378
|
+
constructor(coordinator: ProcessAgentCoordinator, tuple: ModelTuple) {
|
|
379
|
+
this.#coordinator = coordinator
|
|
380
|
+
this.#tuple = tuple
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
get held(): boolean {
|
|
384
|
+
return this.#state === "held"
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async lend<T>(wait: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
388
|
+
if (this.#state !== "held") throw new Error(`Cannot lend an Agent permit while it is ${this.#state}`)
|
|
389
|
+
this.#state = "lent"
|
|
390
|
+
this.#coordinator.releaseSlot()
|
|
391
|
+
|
|
392
|
+
let succeeded = false
|
|
393
|
+
let result: T | undefined
|
|
394
|
+
let failure: unknown
|
|
395
|
+
try {
|
|
396
|
+
result = await wait()
|
|
397
|
+
succeeded = true
|
|
398
|
+
} catch (error) {
|
|
399
|
+
failure = error
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (this.#state === "lent") {
|
|
403
|
+
const reacquireAbort = new AbortController()
|
|
404
|
+
const forwardAbort = () => reacquireAbort.abort(signal?.reason)
|
|
405
|
+
if (signal?.aborted) forwardAbort()
|
|
406
|
+
else signal?.addEventListener("abort", forwardAbort, { once: true })
|
|
407
|
+
this.#reacquireAbort = reacquireAbort
|
|
408
|
+
try {
|
|
409
|
+
await this.#coordinator.reacquire(this.#tuple, reacquireAbort.signal)
|
|
410
|
+
if (this.currentState() === "released") {
|
|
411
|
+
this.#coordinator.releaseSlot()
|
|
412
|
+
throw abortError(reacquireAbort.signal)
|
|
413
|
+
}
|
|
414
|
+
this.#state = "held"
|
|
415
|
+
} catch (error) {
|
|
416
|
+
this.#state = "released"
|
|
417
|
+
throw error
|
|
418
|
+
} finally {
|
|
419
|
+
signal?.removeEventListener("abort", forwardAbort)
|
|
420
|
+
this.#reacquireAbort = undefined
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!succeeded) throw failure
|
|
424
|
+
return result as T
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
release(): void {
|
|
428
|
+
if (this.#state === "released") return
|
|
429
|
+
if (this.#state === "held") this.#coordinator.releaseSlot()
|
|
430
|
+
this.#state = "released"
|
|
431
|
+
this.#reacquireAbort?.abort(new Error("Agent permit released while waiting to reacquire"))
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private currentState(): "held" | "lent" | "released" {
|
|
435
|
+
return this.#state
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function createAgentCoordinator(maxConcurrency: number): AgentCoordinator {
|
|
440
|
+
return new ProcessAgentCoordinator(maxConcurrency)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function getAgentCoordinator(maxConcurrency = 4): AgentCoordinator {
|
|
444
|
+
const globals = globalThis as unknown as Record<symbol, unknown>
|
|
445
|
+
const existing = globals[AGENT_COORDINATOR_SYMBOL]
|
|
446
|
+
if (existing !== undefined) {
|
|
447
|
+
if (!isAgentCoordinator(existing)) throw new Error("Incompatible process-global Lovely Agents coordinator")
|
|
448
|
+
return existing
|
|
449
|
+
}
|
|
450
|
+
const coordinator = createAgentCoordinator(maxConcurrency)
|
|
451
|
+
globals[AGENT_COORDINATOR_SYMBOL] = coordinator
|
|
452
|
+
return coordinator
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Independent process permits; Bash residents still bind in the main coordinator. */
|
|
456
|
+
export function getBashCoordinator(maxConcurrency = 4): AgentCoordinator {
|
|
457
|
+
const globals = globalThis as unknown as Record<symbol, unknown>
|
|
458
|
+
const existing = globals[BASH_COORDINATOR_SYMBOL]
|
|
459
|
+
if (existing !== undefined) {
|
|
460
|
+
if (!isAgentCoordinator(existing)) throw new Error("Incompatible process-global Lovely Bash coordinator")
|
|
461
|
+
return existing
|
|
462
|
+
}
|
|
463
|
+
const coordinator = createAgentCoordinator(maxConcurrency)
|
|
464
|
+
globals[BASH_COORDINATOR_SYMBOL] = coordinator
|
|
465
|
+
return coordinator
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function isAgentCoordinator(value: unknown): value is AgentCoordinator {
|
|
469
|
+
if (typeof value !== "object" || value === null) return false
|
|
470
|
+
const candidate = value as Partial<AgentCoordinator>
|
|
471
|
+
return (
|
|
472
|
+
candidate.version === AGENT_COORDINATOR_VERSION &&
|
|
473
|
+
typeof candidate.acquire === "function" &&
|
|
474
|
+
typeof candidate.reserve === "function" &&
|
|
475
|
+
typeof candidate.withLentPermit === "function" &&
|
|
476
|
+
typeof candidate.setMaxConcurrency === "function" &&
|
|
477
|
+
typeof candidate.bindResident === "function" &&
|
|
478
|
+
typeof candidate.bindNotificationRoute === "function" &&
|
|
479
|
+
typeof candidate.bindSessionContext === "function"
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function tupleKey(tuple: ModelTuple): string {
|
|
484
|
+
return `${tuple.provider}\0${tuple.model}`
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function assertTuple(tuple: ModelTuple): void {
|
|
488
|
+
if (!tuple.provider || !tuple.model || tuple.provider.includes("\0") || tuple.model.includes("\0")) {
|
|
489
|
+
throw new Error("Model tuple provider and model must be nonempty and contain no NUL bytes")
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function assertConcurrency(limit: number): void {
|
|
494
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("maxConcurrency must be a positive safe integer")
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function assertRegistryKey(key: string): void {
|
|
498
|
+
if (!key) throw new Error("Coordinator registry keys must be nonempty")
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function abortError(signal: AbortSignal): Error {
|
|
502
|
+
if (signal.reason instanceof Error) return signal.reason
|
|
503
|
+
const error = new Error(signal.reason === undefined ? "Agent scheduling aborted" : String(signal.reason))
|
|
504
|
+
error.name = "AbortError"
|
|
505
|
+
return error
|
|
506
|
+
}
|