-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathindex.ts
More file actions
777 lines (726 loc) · 31.1 KB
/
Copy pathindex.ts
File metadata and controls
777 lines (726 loc) · 31.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
/**
* Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them
* through the agent/session registries, and owns their ordered teardown.
*
* @module @deepseek-ai/dsh-agent-loop
*/
import { Context, FiberState, Service } from '@deepseek-ai/cordis'
import { randomUUID } from 'node:crypto'
import z from '@deepseek-ai/schemastery'
import { z as zod } from 'zod'
import { brandString } from '@deepseek-ai/dsh-brand'
import { emitAgentEvent } from '@deepseek-ai/dsh-agent'
import type {
Agent,
AgentFactory,
AgentHandle,
AgentOptions,
AgentSetup,
CreateAgentOptions,
ResumeAgentOptions,
SessionStartSource,
TurnBoundaryProjection,
} from '@deepseek-ai/dsh-agent'
import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-settings'
import { SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { ReactLoopAgent } from './agent.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
/** Fiber states that cannot own or serve a new lifecycle. */
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
FiberState.UNLOADING,
FiberState.DISPOSED,
FiberState.FAILED,
])
const turnBoundaryProjectionSchema: zod.ZodType<TurnBoundaryProjection> = zod.object({
openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(),
lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(),
lastStepBoundary: zod.object({
kind: zod.union([zod.literal('start'), zod.literal('end')]),
seq: zod.number().int().nonnegative().transform(SessionSeq),
}).nullable(),
lastTurn: zod.number().int().nonnegative(),
})
/** Host projection of agent turn and step boundaries. */
export const turnBoundaryProjectionDefinition = {
key: 'turnBoundary',
stateVersion: 2,
stateSchema: turnBoundaryProjectionSchema,
init: () => ({
openTurnStartSeq: null,
lastStepStartSeq: null,
lastStepBoundary: null,
lastTurn: 0,
}),
apply: (state, event) => {
switch (event.type) {
case 'turn/start':
return {
...state,
openTurnStartSeq: event.seq,
lastTurn: event.data.turn,
}
case 'turn/end':
return {
...state,
openTurnStartSeq: null,
}
case 'step/start':
return {
...state,
lastStepStartSeq: event.seq,
lastStepBoundary: { kind: 'start', seq: event.seq },
}
case 'step/end':
return {
...state,
lastStepBoundary: { kind: 'end', seq: event.seq },
}
default:
return state
}
},
} satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection>
/** Factory-level ownership: live agent teardowns plus config startup work. */
class FactoryOwnership {
private accepting = true
private readonly teardown = new AbortController()
private readonly inactive = Promise.withResolvers<void>()
private readonly liveAgents = new Set<() => Promise<void>>()
private startupTasks = new Set<Promise<void>>()
constructor(private readonly fiber: Context['fiber']) {}
/** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */
get signal(): AbortSignal {
return this.teardown.signal
}
isActive(): boolean {
return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
}
/** Track one live agent's shared teardown until it has run. */
track(dispose: () => Promise<void>): () => void {
this.liveAgents.add(dispose)
return () => { this.liveAgents.delete(dispose) }
}
/** Join config startup work that begins before an agent exists. */
trackStartup(job: Promise<void>): void {
this.startupTasks.add(job)
const forget = () => { this.startupTasks.delete(job) }
void job.then(forget, forget)
}
/** Join one public create/resume continuation; factory dispose awaits its settlement. */
trackWrapper(job: Promise<unknown>): void {
this.trackStartup(job.then(() => undefined, () => undefined))
}
/** Resolve `task`, or stop waiting when factory teardown begins. */
async waitWhileActive(job: Promise<void>): Promise<void> {
await Promise.race([job, this.inactive.promise])
}
async dispose(): Promise<void> {
this.accepting = false
this.teardown.abort(new Error('agent loop is not active'))
this.inactive.resolve()
await Promise.all([
...[...this.liveAgents].map(dispose => dispose()),
...this.startupTasks,
])
}
}
/** Await `operation`, or throw the signal's reason as soon as it aborts. */
async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal, id: SessionId): Promise<T> {
const toAbortError = (): Error => signal.reason instanceof Error
? signal.reason
: new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
if (signal.aborted) throw toAbortError()
const aborted = Promise.withResolvers<never>()
const listener = (): void => { aborted.reject(toAbortError()) }
signal.addEventListener('abort', listener, { once: true })
try {
return await Promise.race([Promise.resolve(operation), aborted.promise])
} finally {
signal.removeEventListener('abort', listener)
}
}
/** Start an abortable operation and release a value that arrives after cancellation. */
async function raceAbortCall<T>(
operation: () => PromiseLike<T> | T,
signal: AbortSignal,
id: SessionId,
releaseAbandoned?: (value: T) => void,
): Promise<T> {
if (signal.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
}
const pending = Promise.resolve().then(operation)
try {
return await raceAbort(pending, signal, id)
} catch (error: unknown) {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while the operation is awaited.
if (signal.aborted && releaseAbandoned !== undefined) {
void pending.then(releaseAbandoned, () => undefined)
}
throw error
}
}
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
function resolveMaxParallelToolCalls(value: number | undefined): number {
const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
throw new Error('maxParallelToolCalls must be a positive integer')
}
return maxParallelToolCalls
}
/** Reject an output-token cap that cannot be represented exactly on the request wire. */
function assertAgentOptions(options: AgentOptions): void {
if (options.maxTokens !== undefined
&& (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) {
throw new TypeError('agent maxTokens must be a positive safe integer')
}
}
/** Prepared-but-unpublished agent resources sharing one memoized teardown. */
interface PreparedAgent {
agent: ReactLoopAgent
/** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
signal: AbortSignal
/** Enter registries, announce, notify session-start, and start the machine. */
publish(source: SessionStartSource): AgentHandle
/** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
dispose(): Promise<void>
}
declare module '@deepseek-ai/cordis' {
interface Context {
agentLoop: AgentLoop
/**
* Launcher-owned exact session identities for configured agents, keyed by
* the agent's config `id` and set with `ctx.provide()` before any Loader
* entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
* owns identity because only it knows whether the session already exists,
* while the `cordis.yml` row keeps the model route as ordinary patchable
* config. An entry with no matching key keeps its configured identity.
*/
configuredAgentIdentities?: ConfiguredAgentIdentities
}
interface Events {
/**
* A declarative agent entry failed before it could publish a live agent.
* Consumers that buffer work for the configured identity use this
* transient signal to reject that work instead of waiting forever. Normal
* factory teardown suppresses failures from the cancelled startup attempt.
* @param payload.sessionId - exact shared agent/session identity that failed startup.
* @param payload.error - persistence, setup, or publication failure.
* @mode emit
*/
'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void
}
}
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
/**
* One launcher-selected session identity for a configured agent. `resume`
* distinguishes rehydrating existing persisted history from creating the
* session fresh under that exact id, which the two config keys express as
* `resumeSessionId` and `sessionId`.
*/
export interface LauncherAgentIdentity {
/** Exact session id to create fresh or resume. */
id: SessionId
/** Resume existing persisted history instead of creating the session fresh. */
resume: boolean
}
/** Launcher-selected identities keyed by the configured agent's `id`. */
export interface ConfiguredAgentIdentities extends Readonly<Record<string, LauncherAgentIdentity>> {}
/**
* Context key a launcher sets before any Loader entry mounts
* (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
* configured agents' session identities without a config key, so an overlay
* repointing the row's model route cannot drop them.
*/
export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
/**
* Apply launcher-owned identities over the configured agents, replacing both
* identity keys for every entry the launcher named so a config-supplied
* identity can never survive alongside a launcher-supplied one.
* @param agents - the configured agent entries.
* @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
* @returns the entries with launcher-owned identities applied.
*/
function applyLauncherIdentities(
agents: Config['agents'],
identities: ConfiguredAgentIdentities | undefined,
): Config['agents'] {
if (identities === undefined) return agents
return agents.map((agent) => {
const identity = identities[agent.id]
if (identity === undefined) return agent
const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
return identity.resume
? { ...rest, resumeSessionId: identity.id }
: { ...rest, sessionId: identity.id }
})
}
/** Settings namespace carrying the tool-call parallelism a user owns. */
export const AGENT_LOOP_SETTINGS_NAMESPACE = 'agent-loop'
/**
* The agent-loop fields a user owns. Deliberately a strict subset of
* {@link Config}: `agents` is a boot-time composition array consumed once when
* the service starts, so a stored change could only look like it had an effect.
*/
export interface AgentLoopSettings {
/** Maximum parallel-safe calls in flight per agent step. */
maxParallelToolCalls: number
}
/** Schema of the agent-loop settings section. */
export const AGENT_LOOP_SETTINGS_SCHEMA: z<AgentLoopSettings> = z.object({
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
})
/** Agent-loop plugin configuration. */
export interface Config {
/**
* Maximum parallel-safe calls in flight per agent step. `1` is serial;
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
*/
maxParallelToolCalls?: number
/** Agents created or resumed at plugin startup. */
agents: (AgentOptions & {
/** Stable config label used in logs and as the fresh combined-id prefix. */
id: string
/** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */
sessionId?: SessionId
/** Optional workspace for a fresh session. */
cwd?: string
/** Persisted session to resume instead of creating a fresh session. */
resumeSessionId?: SessionId
})[]
}
/** Agent-loop configuration after defaults and load-time validation. */
type ResolvedConfig = Config & { maxParallelToolCalls: number }
/** Reject self-contained identity conflicts before any configured agent starts. */
function validateConfiguredAgents(agents: Config['agents']): void {
const exactIdentities = new Map<SessionId, string>()
for (const { id, sessionId, resumeSessionId } of agents) {
const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== ''
if (sessionId !== undefined && hasResumeId) {
throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
}
const exactIdentity = hasResumeId ? resumeSessionId : sessionId
if (exactIdentity === undefined) continue
const firstId = exactIdentities.get(exactIdentity)
if (firstId !== undefined) {
throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`)
}
exactIdentities.set(exactIdentity, id)
}
}
/** Concrete agent factory and driver service. */
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt', 'sessionProjections']
/** Runtime schema for declarative agents. */
static Config = z.object({
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
agents: z.array(z.object({
id: z.string().required(),
sessionId: z.string().min(1),
provider: z.string(),
model: z.string(),
reasoningEffort: z.string().min(1) as z<ReturnType<typeof ReasoningEffortId>>,
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
cwd: z.string(),
resumeSessionId: z.string(),
})).default([]),
}) as z<Config>
/** Validated configuration owned by the agent-loop service. */
readonly config: ResolvedConfig
private readonly ownership: FactoryOwnership
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
private readonly runtime: { ctx: Context }
constructor(ctx: Context, config: Config) {
super(ctx, 'agentLoop')
const entry: AgentLoopSettings = {
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
}
let source: () => AgentLoopSettings = () => entry
this.config = {
...config,
agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
// Read through on every scheduler decision: `tool-calls.ts` destructures
// this at the start of each group, so a committed change caps the next
// group without disturbing the one in flight.
get maxParallelToolCalls() {
return source().maxParallelToolCalls
},
}
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.installSection(ctx, AGENT_LOOP_SETTINGS_NAMESPACE, AGENT_LOOP_SETTINGS_SCHEMA, entry, {
// The schema admits any integer above zero; `resolveMaxParallelToolCalls`
// owns the whole rule, so refusing here keeps the running scheduler on
// its last good cap instead of failing at the next tool group.
validate: value => void resolveMaxParallelToolCalls(value.maxParallelToolCalls),
setSource: (current) => {
source = current
},
// Nothing is derived from the cap: the getter above is the only reader.
onChange: () => {},
})
})
validateConfiguredAgents(this.config.agents)
// Register only after every config validation above has passed, so a
// rejected constructor leaves no projection unit behind.
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
this.ownership = new FactoryOwnership(ctx.fiber)
this.runtime = { ctx }
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
const meta = cwd === undefined ? {} : { cwd }
if (resumeSessionId === undefined || resumeSessionId === '') {
const configuredId = sessionId ?? brandString<SessionId>(`${id}-session-${randomUUID()}`)
const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
if (persistence === undefined) {
this.create(configuredId, options, meta)
} else {
const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
})
this.ownership.trackStartup(startup)
}
continue
}
ctx.effect(() => {
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
void this.resumeWith(ctx, childCtx.sessionPersistence, {
resumeSessionId,
agentOptions: options,
}).catch((error: unknown) => {
this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
})
})
return fiber.dispose
}, `agentLoop.resume(${id})`)
}
}
/** Report a contained declarative-start failure to identity-bound consumers. */
private reportConfiguredStartupFailure(
configId: string,
action: 'restore' | 'resume',
sessionId: SessionId,
error: unknown,
): void {
if (!this.ownership.isActive()) return
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
const args: unknown[] = ['agent-loop/config-start-failed', { sessionId, error }]
for (const callback of this.ctx.events.dispatch('emit', args)) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((listenerError: unknown) => {
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`)
})
} catch (listenerError: unknown) {
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
}
}
}
/** Restore a materialized exact config identity on remount, or create it on first use. */
private async restoreOrCreateConfigured(
ownerCtx: Context,
persistence: SessionPersistence,
sessionId: SessionId,
agentOptions: AgentOptions,
meta: Pick<SessionHeader, 'cwd'>,
): Promise<void> {
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
if (!this.ownership.isActive()) return
try {
await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
return
} catch (error: unknown) {
if (!this.ownership.isActive()) return
// A load is the per-id serialization barrier for eager write-behind and
// lifecycle retirement. Only a genuinely absent artifact falls back to
// first creation; corruption and backend failures stay loud.
const exists = (await persistence.list()).some(header => header.id === sessionId)
if (exists) throw error
}
this.create(sessionId, agentOptions, meta)
}
/** Wait for a draining same-id lifecycle to finish registry teardown. */
private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
// Only an id still occupying a registry needs waiting for; a live healthy
// occupant is a collision the create/resume below will surface itself.
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) return
const released = Promise.withResolvers<void>()
const checkReleased = (): void => {
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
released.resolve()
}
}
const disposeAgentListener = ownerCtx.on('agent/disposed', () => { checkReleased() })
const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
try {
checkReleased()
await this.ownership.waitWhileActive(released.promise)
} finally {
disposeAgentListener()
disposeSessionListener()
}
}
/**
* Construct the driver, scope, and one memoized reverse teardown for a new
* agent. The teardown is registered with the factory and the owner fiber
* BEFORE publication, so a mid-setup unload rolls everything back; `signal`
* fuses caller cancellation with lifecycle teardown for setup awaits.
*/
private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent {
assertAgentOptions(options)
ownerCtx.fiber.assertActive()
// Every caller reaches prepare() synchronously from a service method
// whose Cordis dispatch already requires the live factory fiber, or
// re-checks ownership itself after its awaits (resume's load barrier).
/* v8 ignore next -- unreachable backstop, see above */
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
if (callerSignal?.aborted) {
throw callerSignal.reason instanceof Error
? callerSignal.reason
: new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason })
}
const loopCtx = this.runtime.ctx
// Deactivation fuses three owners, each with its own reason: the caller's
// cancellation signal, the owner fiber's unload, and factory teardown.
// It is registered BEFORE any resource exists, over mutable slots, so an
// unload arriving while the scope is still minting finds a working
// disposer instead of a leak.
const abort = new AbortController()
const onCallerAbort = (): void => {
abort.abort(callerSignal?.reason instanceof Error
? callerSignal.reason
: new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason }))
}
const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) }
callerSignal?.addEventListener('abort', onCallerAbort, { once: true })
this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true })
let machine: ReactLoopAgent | undefined
let detachSession: (() => void) | undefined
let detachAgent: (() => void) | undefined
let disposing: Promise<void> | undefined
const machineReady = Promise.withResolvers<void>()
// Reverse teardown, memoized so every racing owner awaits one quiescence:
// stop the machine, leave the registries, unwind the scope, release
// bookkeeping.
const dispose = (ownerTriggered = false): Promise<void> => (disposing ??= (async () => {
abort.abort(new Error(`agent "${id}" lifecycle disposed`))
callerSignal?.removeEventListener('abort', onCallerAbort)
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
try {
// Disposal IS a disposed-cause cancel followed by quiescence. New work
// sent after this point is the sender's bug — the registries are about
// to drop the agent, so nothing should still hold it.
if (machine === undefined) await machineReady.promise
if (machine !== undefined) {
machine.cancel({ kind: 'disposed' })
await machine.whenIdle()
await machine.scope.dispose()
}
} finally {
try {
detachAgent?.()
detachSession?.()
} finally {
untrack()
if (!ownerTriggered) await unfollowOwner()
}
}
})())
const untrack = this.ownership.track(dispose)
let unfollowOwner: () => Promise<void> | void
try {
unfollowOwner = ownerCtx.effect(() => () => {
// Owner disposal owns the same quiescence boundary. Its teardown skips
// unregistering this already-running owner effect from inside itself.
if (disposing !== undefined) return
abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
return dispose(true)
}, `agentLoop.lifecycle(${id})`)
/* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */
} catch (error: unknown) {
untrack()
callerSignal?.removeEventListener('abort', onCallerAbort)
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
throw error
}
/* v8 ignore stop */
const assertLive = (): void => {
if (!abort.signal.aborted) return
// Every fused abort source carries an Error reason: onCallerAbort and
// raceAbort wrap non-Error caller reasons, and the factory/lifecycle
// owners abort with constructed Errors.
/* v8 ignore next -- unreachable String() arm, see above */
throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
}
try {
const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
machineReady.resolve()
assertLive()
return {
agent,
signal: abort.signal,
publish: (source) => {
assertLive()
detachSession = agent.ctx.sessions.enter(session)
detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent)
agent.ctx.sessions.announce(session)
assertLive()
loopCtx.agents.announce(agent)
assertLive()
// A synchronous announce/session-start listener may have started
// teardown; the machine is already live (delivery works from the
// session-start extension point), so only the liveness recheck is owed.
emitAgentEvent(loopCtx, agent, 'agent/session-start', { source })
assertLive()
return { agent, dispose }
},
dispose,
}
} catch (error: unknown) {
machineReady.resolve()
void dispose()
throw error
}
}
/**
* Create an agent and session under one caller-supplied identity, owned by
* the accessing fiber. Constructor-driven config calls mint a fresh combined
* id before entering this boundary.
* @param id - shared agent/session identity.
* @param options - concrete loop options.
* @param meta - optional fresh-session workspace metadata.
* @returns the published running agent.
*/
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta }))
const prepared = this.prepare(this.ctx, id, options, preparation.session)
try {
return prepared.publish('startup').agent
} catch (error: unknown) {
void prepared.dispose()
throw error
}
}
/**
* Create an owned agent on a caller-supplied session id.
* @param ownerCtx - caller context that structurally owns the lifecycle.
* @param options - identities, session seed/metadata, loop options, setup, and cancellation.
* @returns the published handle.
*/
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
...options.seed === undefined ? {} : { seed: options.seed },
...options.meta === undefined ? {} : { meta: options.meta },
...options.inheritedEventCount === undefined ? {} : { inheritedEventCount: options.inheritedEventCount },
}))
const published = this.setupAndPublish(
ownerCtx,
options.sessionId,
preparation,
options.agentOptions ?? {},
options.setup,
options.signal,
'startup',
)
this.ownership.trackWrapper(published)
return published
}
/** Prepare one Agent around an acquired Session, run setup, and publish it. */
private async setupAndPublish(
ownerCtx: Context,
id: SessionId,
preparation: SessionPreparation,
agentOptions: AgentOptions,
setup: AgentSetup | undefined,
signal: AbortSignal | undefined,
source: SessionStartSource,
): Promise<AgentHandle> {
using ownedPreparation = preparation
const session = ownedPreparation.session
const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal)
try {
const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
setupCommit?.commit()
return prepared.publish(source)
} catch (error: unknown) {
await prepared.dispose()
throw error
}
}
/**
* Resume an owned agent from the configured persistence service.
* @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
* @param options - persisted identity, loop options, setup, and cancellation.
* @returns the published handle.
*/
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
const persistence = this.runtime.ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
}
return this.resumeWith(ownerCtx, persistence, options)
}
/** Resume through an explicit persistence handle used by the deferred config path. */
private resumeWith(
ownerCtx: Context,
persistence: SessionPersistence,
options: ResumeAgentOptions,
): Promise<AgentHandle> {
const id = options.resumeSessionId
const published = (async () => {
// The load may outlive its owner: race it against caller cancellation,
// owner-fiber unload, and factory teardown so a never-settling backend
// cannot pin the identity.
const ownerAbort = new AbortController()
const unfollowOwner = ownerCtx.effect(() => () => {
ownerAbort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
}, `agentLoop.resume-load(${id})`)
const fused = AbortSignal.any([
...options.signal === undefined ? [] : [options.signal],
ownerAbort.signal,
this.ownership.signal,
])
let preparation: SessionPreparation | undefined
try {
try {
preparation = await raceAbortCall(
() => persistence.prepare(id, fused),
fused,
id,
(abandoned) => { abandoned[Symbol.dispose]() },
)
} finally {
await unfollowOwner()
}
ownerCtx.fiber.assertActive()
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
return await this.setupAndPublish(
ownerCtx,
id,
preparation,
options.agentOptions ?? {},
options.setup,
options.signal,
'resume',
)
} finally {
preparation?.[Symbol.dispose]()
}
})()
this.ownership.trackWrapper(published)
return published
}
}
export default AgentLoop