@stackstackstack/dsh-agent-loop 0.1.5

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/lib/index.js ADDED
@@ -0,0 +1,1295 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import { randomUUID } from "node:crypto";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { Inbox, agentEvents, assembleContextFor, emitAgentEvent } from "@stackstackstack/dsh-agent";
5
+ import { BlockAssembler, LlmError, assertNever, createAssistantMessage, createToolResultMessage, createUserMessage, deepFreeze, errorChain, markAgentLoopRequest } from "@stackstackstack/dsh-llm";
6
+ import { installSettingsSection, settingsNamespace } from "@stackstackstack/dsh-settings";
7
+ import { SessionId, SessionPreparation, canonicalHeader, headerEquals, isReplacementSurfaceEvent } from "@stackstackstack/dsh-session";
8
+ import { createScope } from "@stackstackstack/dsh-scope";
9
+ import { joinContextSections, renderContextSections, renderPrompt } from "@stackstackstack/dsh-system-prompt";
10
+ import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER } from "@stackstackstack/dsh-tools";
11
+ //#region lib/types/runtime-context.js
12
+ /**
13
+ * Durable projection state for dynamic runtime context.
14
+ * @module @stackstackstack/dsh-agent-loop/runtime-context
15
+ */
16
+ const SOURCE = "@stackstackstack/dsh-system-prompt";
17
+ const CLEARED = "Current runtime context: none. Earlier runtime-context snapshots no longer apply.";
18
+ function isOwned(message) {
19
+ return message.source.kind === "plugin" && message.source.plugin === SOURCE;
20
+ }
21
+ function textOf(message) {
22
+ const [block] = message.content;
23
+ return message.content.length === 1 && block?.type === "text" ? block.text : void 0;
24
+ }
25
+ /** Tracks the last retained runtime-context snapshot without owning its commit. */
26
+ var RuntimeContextProjection = class {
27
+ /** `undefined` means no snapshot ever existed; `null` means none is retained. */
28
+ retained;
29
+ /**
30
+ * Restore projection state once, then follow authoritative session events.
31
+ * @param ctx - agent-scoped event context.
32
+ * @param session - session receiving projected messages.
33
+ */
34
+ constructor(ctx, session) {
35
+ const surface = new Set(session.surface.nodes);
36
+ for (let index = session.events.length - 1; index >= 0; index -= 1) {
37
+ const event = session.events[index];
38
+ if (event?.type !== "user/message" || !isOwned(event.data)) continue;
39
+ this.retained ??= null;
40
+ if (surface.has(event.seq)) {
41
+ this.retained = {
42
+ seq: event.seq,
43
+ text: textOf(event.data)
44
+ };
45
+ break;
46
+ }
47
+ }
48
+ ctx.on("session/event", (subject, event) => {
49
+ if (subject !== session) return;
50
+ if (event.type === "user/message" && isOwned(event.data)) this.retained = {
51
+ seq: event.seq,
52
+ text: textOf(event.data)
53
+ };
54
+ else if (this.retained && isReplacementSurfaceEvent(event) && event.sourceEventSeqs?.includes(this.retained.seq) === true) this.retained = null;
55
+ });
56
+ }
57
+ /**
58
+ * Create an uncommitted snapshot only when the retained value differs.
59
+ * @param current - fully rendered dynamic context.
60
+ * @param sections - named contributions that formed the current snapshot.
61
+ * @returns a candidate user message, or `undefined` when no update is needed.
62
+ */
63
+ project(current, sections) {
64
+ if (this.retained === void 0 && current.length === 0) return;
65
+ const snapshot = current.length === 0 ? CLEARED : current;
66
+ if (this.retained?.text === snapshot) return;
67
+ return createUserMessage({
68
+ content: [{
69
+ type: "text",
70
+ text: snapshot
71
+ }],
72
+ source: sections.length === 0 ? {
73
+ kind: "plugin",
74
+ plugin: SOURCE
75
+ } : {
76
+ kind: "plugin",
77
+ plugin: SOURCE,
78
+ form: "snapshot",
79
+ sections
80
+ }
81
+ });
82
+ }
83
+ };
84
+ //#endregion
85
+ //#region lib/types/tool-calls.js
86
+ /**
87
+ * Schedules one assistant step's tool calls. Exclusive calls form barriers;
88
+ * parallel calls use a bounded rolling pool and are reclassified before start.
89
+ * Dispatch may overlap, while policy, results, and result context remain
90
+ * model-ordered. Abort or an internal scheduler failure stops replenishment
91
+ * and drains started calls.
92
+ *
93
+ * Abort records synthetic error results for skipped calls so replay stays
94
+ * valid. A terminal scheduler failure preserves already-recorded `tool/call`
95
+ * events without fabricating results.
96
+ * @module dsh-agent-loop/tool-calls
97
+ */
98
+ /**
99
+ * Schedule one assistant step's tool calls by their live concurrency mode.
100
+ * Ordinary completion and abort commit started-call results in order. Abort
101
+ * drains them, records synthetic results for unstarted calls, and returns with
102
+ * the signal still aborted after accepting started-call context through the
103
+ * caller-supplied acceptor (the machine stages it in its next-step inbox for the
104
+ * step boundary). An internal scheduler failure stops new dispatches, drains
105
+ * already-started dispatches, and rejects with the first failure without
106
+ * fabricating tool results.
107
+ * The committed step's AgentLoop driver boundary supplies the initiating Agent
108
+ * that becomes each explicit {@link ToolExecutionInput.agent}.
109
+ *
110
+ * @param ctx - loop context that owns the tool registry and carries the initiating Agent.
111
+ * @param turn - current turn number.
112
+ * @param step - current step number.
113
+ * @param toolCalls - assistant calls in model order.
114
+ * @param signal - abort signal shared by the step.
115
+ * @param acceptContext - accepts committed result context for the next step boundary.
116
+ */
117
+ async function executeToolCalls(ctx, turn, step, toolCalls, signal, acceptContext) {
118
+ const agent = ctx.agents.requireInitiator();
119
+ const { session } = agent;
120
+ const planned = toolCalls.map((block) => ({
121
+ block,
122
+ exec: {
123
+ callId: block.id,
124
+ name: block.name,
125
+ arguments: parseArguments(block.arguments),
126
+ agent,
127
+ signal
128
+ }
129
+ }));
130
+ let next = 0;
131
+ let concluded = false;
132
+ while (next < planned.length) {
133
+ const first = planned[next];
134
+ const mode = ctx.tools.executionMode(first.exec).kind;
135
+ const outcome = await runGroup(ctx, turn, step, mode === "parallel" ? planned.slice(next) : [first], mode, signal, acceptContext);
136
+ next += outcome.consumed;
137
+ concluded ||= outcome.concluded;
138
+ if (outcome.aborted) {
139
+ for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block);
140
+ return { concluded };
141
+ }
142
+ }
143
+ return { concluded };
144
+ }
145
+ /** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
146
+ function parseArguments(raw) {
147
+ try {
148
+ return raw ? JSON.parse(raw) : {};
149
+ } catch {
150
+ return raw;
151
+ }
152
+ }
153
+ /**
154
+ * Run one exclusive barrier or parallel pool. Later calls are reclassified
155
+ * before start; an exclusive reclassification waits for the current pool to
156
+ * drain and remains for the caller's next barrier. Results and contexts commit
157
+ * in model order. Abort stops starts, drains and commits started calls, accepts
158
+ * their contexts into the owning batch, records results for skipped calls, and
159
+ * returns an aborted outcome. Scheduler failure drains dispatches without
160
+ * committing synthetic recovery results.
161
+ */
162
+ async function runGroup(ctx, turn, step, group, mode, signal, acceptContext) {
163
+ const { session } = ctx.agents.requireInitiator();
164
+ const { maxParallelToolCalls } = ctx.agentLoop.config;
165
+ const slots = group.map(() => void 0);
166
+ const callSeqs = group.map(() => -1);
167
+ let nextToStart = 0;
168
+ let committed = 0;
169
+ let started = 0;
170
+ let aborted = signal.aborted;
171
+ let concluded = false;
172
+ let schedulerFailure;
173
+ const throwSchedulerFailure = () => {
174
+ if (schedulerFailure !== void 0) throw schedulerFailure.error;
175
+ };
176
+ const commitReady = async () => {
177
+ while (committed < group.length) {
178
+ const slot = slots[committed];
179
+ if (slot === void 0) break;
180
+ const call = group[committed];
181
+ const result = slot.needsPost ? await ctx.tools[TOOL_RUNTIME_SCHEDULER].finalize(slot.exec, slot.result) : ctx.tools[TOOL_RUNTIME_SCHEDULER].finish(slot.exec, slot.result);
182
+ appendToolResult(session, turn, step, call.block, result, callSeqs[committed]);
183
+ for (const context of result.additionalContexts ?? []) acceptContext(context);
184
+ concluded ||= result.concludesTurn === true;
185
+ committed++;
186
+ }
187
+ };
188
+ const inFlight = /* @__PURE__ */ new Map();
189
+ const startCall = async (index) => {
190
+ const call = group[index];
191
+ callSeqs[index] = appendToolCall(session, turn, step, call.block);
192
+ started++;
193
+ const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec);
194
+ throwSchedulerFailure();
195
+ switch (prepared.kind) {
196
+ case "dispatch": {
197
+ const promise = ctx.tools[TOOL_RUNTIME_SCHEDULER].dispatch(prepared.exec).then((outcome) => {
198
+ slots[index] = {
199
+ exec: prepared.exec,
200
+ result: outcome.result,
201
+ needsPost: outcome.kind === "post-result"
202
+ };
203
+ return index;
204
+ }, (error) => {
205
+ schedulerFailure ??= { error };
206
+ return index;
207
+ });
208
+ inFlight.set(index, promise);
209
+ break;
210
+ }
211
+ case "post-result":
212
+ slots[index] = {
213
+ exec: prepared.exec,
214
+ result: prepared.result,
215
+ needsPost: true
216
+ };
217
+ break;
218
+ case "final-result":
219
+ slots[index] = {
220
+ exec: prepared.exec,
221
+ result: prepared.result,
222
+ needsPost: false
223
+ };
224
+ break;
225
+ /* v8 ignore next -- closed-union exhaustiveness guard */
226
+ default: assertNever(prepared, "tool-call scheduler prepare result");
227
+ }
228
+ };
229
+ const fillPool = async () => {
230
+ while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
231
+ const nextCall = group[nextToStart];
232
+ if (nextToStart > 0 && mode === "parallel" && ctx.tools.executionMode(nextCall.exec).kind !== "parallel") break;
233
+ await startCall(nextToStart);
234
+ nextToStart++;
235
+ throwSchedulerFailure();
236
+ await commitReady();
237
+ throwSchedulerFailure();
238
+ if (signal.aborted) aborted = true;
239
+ }
240
+ };
241
+ try {
242
+ await fillPool();
243
+ while (inFlight.size > 0) {
244
+ const settledIndex = await Promise.race(inFlight.values());
245
+ inFlight.delete(settledIndex);
246
+ throwSchedulerFailure();
247
+ await commitReady();
248
+ throwSchedulerFailure();
249
+ if (signal.aborted) aborted = true;
250
+ await fillPool();
251
+ }
252
+ } catch (error) {
253
+ schedulerFailure ??= { error };
254
+ await Promise.allSettled(inFlight.values());
255
+ throw schedulerFailure.error;
256
+ }
257
+ if (aborted) {
258
+ for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block);
259
+ return {
260
+ consumed: group.length,
261
+ aborted: true,
262
+ concluded
263
+ };
264
+ }
265
+ /* v8 ignore next -- unreachable: a non-aborted group commits every started call */
266
+ if (committed !== started) throw new Error("tool-call scheduler: uncommitted settled calls");
267
+ return {
268
+ consumed: started,
269
+ aborted: false,
270
+ concluded
271
+ };
272
+ }
273
+ /** Append the durable call/result pair for a model call skipped after cancellation. */
274
+ function appendSkippedToolCall(session, turn, step, block) {
275
+ const callSeq = appendToolCall(session, turn, step, block);
276
+ appendToolResult(session, turn, step, block, {
277
+ content: [{
278
+ type: "text",
279
+ text: "Error: tool call aborted before dispatch"
280
+ }],
281
+ isError: true,
282
+ error: {
283
+ message: "tool call aborted before dispatch",
284
+ info: {
285
+ name: "AbortError",
286
+ code: TOOL_ABORTED_BEFORE_DISPATCH
287
+ }
288
+ }
289
+ }, callSeq);
290
+ }
291
+ /** Append a started call and return the event seq that its result must cite. */
292
+ function appendToolCall(session, turn, step, block) {
293
+ return session.append("tool/call", {
294
+ turn,
295
+ step,
296
+ callId: block.id,
297
+ name: block.name,
298
+ arguments: block.arguments
299
+ }).seq;
300
+ }
301
+ /** Append a model-ordered result linked to its call event. */
302
+ function appendToolResult(session, turn, step, block, result, callSeq) {
303
+ const message = createToolResultMessage({
304
+ callId: block.id,
305
+ content: result.content,
306
+ isError: result.isError
307
+ });
308
+ session.append("tool/result", {
309
+ turn,
310
+ step,
311
+ message,
312
+ ...result.error?.info ? { error: result.error.info } : {},
313
+ ...result.meta !== void 0 ? { meta: result.meta } : {}
314
+ }, {
315
+ surfaceOp: "append",
316
+ sourceEventSeqs: [callSeq]
317
+ });
318
+ }
319
+ //#endregion
320
+ //#region lib/types/agent.js
321
+ /**
322
+ * Default Agent driver over queued turns and step-boundary input. Every request
323
+ * is derived from the session log.
324
+ * @module dsh-agent-loop/agent
325
+ */
326
+ /** Remove adapter-derived values before plugins propose the next request config. */
327
+ function requestProposal(header) {
328
+ if (header.adapterDefaults === void 0) return header.config;
329
+ const proposal = { ...header.config };
330
+ if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort;
331
+ if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens;
332
+ return proposal;
333
+ }
334
+ /** Drives one session through turn and step boundaries. */
335
+ var ReactLoopAgent = class {
336
+ loopCtx;
337
+ id;
338
+ options;
339
+ session;
340
+ inbox;
341
+ phase;
342
+ activityDone = Promise.resolve();
343
+ /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */
344
+ scope;
345
+ ctx;
346
+ /** Fused dispatcher, built once in the constructor so hot-path dispatches never allocate. */
347
+ dispatch;
348
+ /** Whether this loop instance has appended its initial/resume request anchor. */
349
+ requestHeaderLogged = false;
350
+ runtimeContext;
351
+ constructor(loopCtx, id, options, session) {
352
+ this.loopCtx = loopCtx;
353
+ this.id = id;
354
+ this.options = options;
355
+ this.session = session;
356
+ this.dispatch = agentEvents(loopCtx, this);
357
+ this.inbox = new Inbox(session, {
358
+ inserted: (message) => {
359
+ this.dispatch.emit("agent/inbox/inserted", { message });
360
+ },
361
+ discarded: (message) => {
362
+ this.dispatch.emit("agent/inbox/discarded", { message });
363
+ },
364
+ claimed: (message, turn) => {
365
+ this.dispatch.emit("agent/inbox/claimed", {
366
+ message,
367
+ turn
368
+ });
369
+ }
370
+ });
371
+ const lastTurn = session.events.findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
372
+ this.phase = {
373
+ kind: "idle",
374
+ lastTurn
375
+ };
376
+ this.scope = createScope(loopCtx, this);
377
+ this.ctx = this.scope.ctx.extend({ agent: this });
378
+ this.runtimeContext = new RuntimeContextProjection(this.ctx, session);
379
+ }
380
+ get status() {
381
+ return this.phase.kind === "idle" || this.phase.kind === "maintenance" ? "idle" : "running";
382
+ }
383
+ /** Commit a phase and publish its externally visible status transition. */
384
+ setPhase(next) {
385
+ const previousStatus = this.status;
386
+ this.phase = next;
387
+ const status = this.status;
388
+ if (status !== previousStatus) this.dispatch.emit("agent/status", { status });
389
+ }
390
+ send(message, target, wakeup) {
391
+ const wakingAfterAbort = wakeup && this.phase.kind !== "idle" && this.phase.abort.signal.aborted;
392
+ const resolvedTarget = wakingAfterAbort ? "next-turn" : target;
393
+ this.inbox.splice(resolvedTarget, Infinity, 0, [message]);
394
+ if (wakeup) this.wakeDriver(wakingAfterAbort);
395
+ }
396
+ followup(input) {
397
+ this.send(input, "next-turn", true);
398
+ }
399
+ steer(input) {
400
+ this.send(input, "next-step", true);
401
+ }
402
+ inject(input) {
403
+ this.send(input, "next-step", false);
404
+ }
405
+ cancel(cause, options = {}) {
406
+ if (!options.keepInbox) {
407
+ this.inbox.clear();
408
+ if (this.phase.kind !== "idle") this.phase.wakeRequested = false;
409
+ }
410
+ if (this.phase.kind !== "idle") this.phase.abort.abort(cause);
411
+ }
412
+ runMaintenance(job) {
413
+ if (this.phase.kind !== "idle") throw new Error(`agent "${this.id}" already has active work`);
414
+ const done = Promise.withResolvers();
415
+ const maintenance = {
416
+ kind: "maintenance",
417
+ abort: new AbortController(),
418
+ lastTurn: this.phase.lastTurn,
419
+ wakeRequested: false
420
+ };
421
+ this.setPhase(maintenance);
422
+ this.activityDone = done.promise;
423
+ return (async () => {
424
+ try {
425
+ return await job(maintenance.abort.signal);
426
+ } finally {
427
+ this.setPhase({
428
+ kind: "idle",
429
+ lastTurn: maintenance.lastTurn
430
+ });
431
+ if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver();
432
+ done.resolve();
433
+ }
434
+ })();
435
+ }
436
+ /**
437
+ * Start one driver, or latch its wake behind maintenance or an aborted
438
+ * activity. A wake sent while idle always opens its turn boundary, even
439
+ * when its message was cleared; only a latched replay is suppressed when
440
+ * the queue no longer holds the wake.
441
+ * @param wakeAfterAbort - the {@link send} classification, captured before
442
+ * the inbox insertion so a reentrant cancel cannot reclassify it.
443
+ */
444
+ wakeDriver(wakeAfterAbort = false) {
445
+ if (this.phase.kind !== "idle") {
446
+ if (this.phase.abort.signal.reason?.kind !== "disposed" && (this.phase.kind === "maintenance" || wakeAfterAbort)) this.phase.wakeRequested = true;
447
+ return;
448
+ }
449
+ const driver = Promise.withResolvers();
450
+ this.activityDone = driver.promise;
451
+ this.setPhase({
452
+ kind: "running",
453
+ abort: new AbortController(),
454
+ turn: this.phase.lastTurn,
455
+ step: 0,
456
+ wakeRequested: false
457
+ });
458
+ this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject);
459
+ }
460
+ async whenIdle() {
461
+ let activity;
462
+ do
463
+ await (activity = this.activityDone);
464
+ while (activity !== this.activityDone);
465
+ }
466
+ /** Report one failure at its live boundary, then preserve it for driver containment. */
467
+ throwError(error) {
468
+ const turn = this.phase.kind === "running" ? this.phase.turn : this.phase.lastTurn;
469
+ const step = this.phase.kind === "running" ? this.phase.step : 0;
470
+ this.dispatch.emit("agent/error", {
471
+ turn,
472
+ step,
473
+ error
474
+ });
475
+ throw error;
476
+ }
477
+ async kick() {
478
+ try {
479
+ while (await this.turn());
480
+ } catch (_error) {} finally {
481
+ /* v8 ignore next -- kick owns a running phase until this driver boundary */
482
+ if (this.phase.kind === "running") {
483
+ const { turn, wakeRequested } = this.phase;
484
+ this.setPhase({
485
+ kind: "idle",
486
+ lastTurn: turn
487
+ });
488
+ if (wakeRequested && this.inbox.hasPending) this.wakeDriver();
489
+ }
490
+ }
491
+ }
492
+ async preStep(target, position) {
493
+ /* v8 ignore next -- private callers establish the running phase before proposing a step */
494
+ if (this.phase.kind !== "running") throw new Error(`agent "${this.id}": pre-step outside running phase`);
495
+ const signal = this.phase.abort.signal;
496
+ const claimed = this.inbox.claim(target, position.turn);
497
+ const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal));
498
+ signal.throwIfAborted();
499
+ const sections = renderContextSections(assembly);
500
+ const context = this.runtimeContext.project(joinContextSections(sections), sections);
501
+ const decision = await this.dispatch.waterfall("agent/pre-step", {
502
+ messages: claimed,
503
+ ...position,
504
+ signal
505
+ }, () => Promise.resolve({
506
+ kind: "enter",
507
+ messages: context === void 0 ? claimed : [...claimed, context]
508
+ }));
509
+ signal.throwIfAborted();
510
+ return decision.kind === "reject" ? decision : {
511
+ ...decision,
512
+ assembly
513
+ };
514
+ }
515
+ /** Open one turn before claiming its first proposed step. */
516
+ async turn() {
517
+ if (this.phase.kind !== "running") this.throwError(/* @__PURE__ */ new Error(`agent "${this.id}": turn without driver reservation`));
518
+ const phase = this.phase;
519
+ const { signal } = phase.abort;
520
+ signal.throwIfAborted();
521
+ const turn = phase.turn + 1;
522
+ try {
523
+ this.session.append("turn/start", { turn });
524
+ } catch (error) {
525
+ this.throwError(error);
526
+ }
527
+ phase.turn = turn;
528
+ let turnEnds = null;
529
+ let target = "next-turn";
530
+ try {
531
+ while (true) {
532
+ signal.throwIfAborted();
533
+ const step = phase.step + 1;
534
+ const decision = await this.preStep(target, {
535
+ turn,
536
+ step
537
+ });
538
+ if (decision.kind === "reject") {
539
+ turnEnds = { kind: "blocked" };
540
+ return false;
541
+ }
542
+ if (turnEnds && decision.messages.length === 0) break;
543
+ if (phase.step === 0 && decision.messages.length === 0) {
544
+ turnEnds = { kind: "completed" };
545
+ return false;
546
+ }
547
+ signal.throwIfAborted();
548
+ this.session.append("step/start", {
549
+ turn,
550
+ step
551
+ });
552
+ phase.step = step;
553
+ try {
554
+ for (const message of decision.messages) this.session.append("user/message", message, { surfaceOp: "append" });
555
+ const stepEnd = await this.step(decision.assembly);
556
+ if (turnEnds === null || turnEnds.kind !== "max-tokens") turnEnds = stepEnd;
557
+ } finally {
558
+ this.session.append("step/end", {
559
+ turn,
560
+ step
561
+ });
562
+ }
563
+ signal.throwIfAborted();
564
+ if (turnEnds && this.inbox.nextStep.length === 0) {
565
+ await this.dispatch.serial("agent/turn-stopping", {
566
+ turn,
567
+ signal
568
+ });
569
+ signal.throwIfAborted();
570
+ }
571
+ if (turnEnds && this.inbox.nextStep.length === 0) break;
572
+ target = "next-step";
573
+ }
574
+ } catch (error) {
575
+ if (signal.aborted) {
576
+ turnEnds = {
577
+ kind: "aborted",
578
+ reason: signal.reason
579
+ };
580
+ throw error;
581
+ }
582
+ turnEnds = {
583
+ kind: "error",
584
+ error: error instanceof LlmError ? error.failure : {
585
+ message: errorChain(error),
586
+ code: "UNKNOWN"
587
+ }
588
+ };
589
+ this.throwError(error);
590
+ } finally {
591
+ try {
592
+ this.session.append("turn/end", {
593
+ turn,
594
+ reason: turnEnds
595
+ });
596
+ } catch (error) {
597
+ this.throwError(error);
598
+ }
599
+ }
600
+ if (!this.inbox.hasPending) return false;
601
+ phase.abort = new AbortController();
602
+ phase.wakeRequested = false;
603
+ phase.step = 0;
604
+ return true;
605
+ }
606
+ async step(assembly) {
607
+ /* v8 ignore next -- private callers establish the running phase before executing a step */
608
+ if (this.phase.kind !== "running") throw new Error(`agent "${this.id}": step outside running phase`);
609
+ const { turn, step, abort: { signal } } = this.phase;
610
+ signal.throwIfAborted();
611
+ const system = renderPrompt(assembly);
612
+ while (true) {
613
+ const { request, preparedCall } = await this.buildRequest(turn, step, assembly.tools, system, this.session.deriveMessages(), signal);
614
+ const assembler = new BlockAssembler();
615
+ const chunkSeqs = [];
616
+ const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request);
617
+ signal.throwIfAborted();
618
+ for await (const chunk of stream) {
619
+ signal.throwIfAborted();
620
+ chunkSeqs.push(this.session.append("assistant/chunk", {
621
+ turn,
622
+ step,
623
+ chunk
624
+ }).seq);
625
+ assembler.push(chunk);
626
+ }
627
+ signal.throwIfAborted();
628
+ const finish = assembler.finish;
629
+ if (finish.kind === "error" || finish.kind === "aborted") {
630
+ const action = await this.dispatch.waterfall("agent/request-error", {
631
+ turn,
632
+ step,
633
+ provider: request.provider,
634
+ failure: finish.failure,
635
+ retryPolicy: preparedCall?.retryPolicy,
636
+ signal
637
+ }, () => Promise.resolve(void 0));
638
+ signal.throwIfAborted();
639
+ if (action?.kind !== "retry") throw new LlmError(finish.failure.message, finish.failure.code, finish.failure);
640
+ continue;
641
+ }
642
+ const message = createAssistantMessage({
643
+ content: assembler.blocks(),
644
+ source: {
645
+ provider: request.provider,
646
+ model: request.model,
647
+ ...assembler.replayState !== void 0 ? { replayState: assembler.replayState } : {}
648
+ }
649
+ });
650
+ this.session.append("assistant/message", {
651
+ turn,
652
+ step,
653
+ message,
654
+ ...assembler.usage === void 0 ? {} : { usage: assembler.usage }
655
+ }, {
656
+ surfaceOp: "append",
657
+ sourceEventSeqs: chunkSeqs
658
+ });
659
+ if (finish.kind === "max-tokens") return { kind: "max-tokens" };
660
+ const toolCalls = message.content.filter((block) => block.type === "tool-call");
661
+ if (toolCalls.length === 0) return { kind: "completed" };
662
+ const { concluded } = await executeToolCalls(this.loopCtx, turn, step, toolCalls, signal, (context) => this.inbox.splice("next-step", this.inbox.nextStep.length, 0, [context]));
663
+ return concluded ? { kind: "completed" } : null;
664
+ }
665
+ }
666
+ /**
667
+ * Compose one frozen request and bind it to the adapter registration that
668
+ * resolved its exact-model defaults.
669
+ */
670
+ async buildRequest(turn, step, tools, system, boundaryMessages, signal) {
671
+ const { session } = this;
672
+ const persistedHeader = session.requestHeader();
673
+ const persistedConfig = persistedHeader?.config;
674
+ const route = {
675
+ provider: this.options.provider ?? "",
676
+ model: this.options.model ?? ""
677
+ };
678
+ const reasoningEffort = persistedConfig?.provider === route.provider && persistedConfig.model === route.model && persistedHeader?.adapterDefaults?.reasoningEffort !== true ? persistedConfig.reasoningEffort : void 0;
679
+ const maxTokens = this.options.maxTokens;
680
+ const seedConfig = deepFreeze(structuredClone(this.requestHeaderLogged ? requestProposal(persistedHeader) : {
681
+ ...route,
682
+ ...reasoningEffort === void 0 ? {} : { reasoningEffort },
683
+ ...maxTokens === void 0 ? {} : { maxTokens }
684
+ }));
685
+ const proposedConfig = await this.dispatch.waterfall("agent/request", {
686
+ turn,
687
+ step,
688
+ signal
689
+ }, () => Promise.resolve(seedConfig));
690
+ signal.throwIfAborted();
691
+ if (!proposedConfig.provider || !proposedConfig.model) throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`);
692
+ let config;
693
+ let preparedCall;
694
+ try {
695
+ preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal);
696
+ config = preparedCall.config;
697
+ } catch (error) {
698
+ if (!(error instanceof LlmError) || error.code !== "NO_ADAPTER") throw error;
699
+ config = proposedConfig;
700
+ }
701
+ signal.throwIfAborted();
702
+ const header = canonicalHeader({
703
+ config,
704
+ ...preparedCall === void 0 ? {} : { adapterDefaults: preparedCall.adapterDefaults },
705
+ ...system ? { system } : {},
706
+ ...tools.length > 0 ? { tools } : {}
707
+ });
708
+ const baseline = this.session.requestHeader();
709
+ if (!this.requestHeaderLogged) {
710
+ this.session.append("request/header", {
711
+ header,
712
+ reason: baseline === void 0 ? "initial" : "resume"
713
+ });
714
+ this.requestHeaderLogged = true;
715
+ } else if (baseline === void 0 || !headerEquals(baseline, header)) this.session.append("request/header", {
716
+ header,
717
+ reason: "change"
718
+ });
719
+ const contextWindow = preparedCall?.context?.contextWindow;
720
+ const requestContext = {
721
+ provider: config.provider,
722
+ model: config.model,
723
+ ...contextWindow === void 0 ? {} : { contextWindow }
724
+ };
725
+ const previousContext = session.requestContext();
726
+ if (previousContext?.provider !== requestContext.provider || previousContext.model !== requestContext.model || previousContext.contextWindow !== requestContext.contextWindow) session.append("request/context", requestContext);
727
+ signal.throwIfAborted();
728
+ return {
729
+ request: markAgentLoopRequest(deepFreeze({
730
+ ...header.config,
731
+ messages: boundaryMessages,
732
+ ...header.system !== void 0 ? { system: header.system } : {},
733
+ ...header.tools !== void 0 ? { tools: header.tools } : {},
734
+ sessionId: this.session.id,
735
+ signal
736
+ })),
737
+ ...preparedCall === void 0 ? {} : { preparedCall }
738
+ };
739
+ }
740
+ };
741
+ //#endregion
742
+ //#region lib/types/constants.js
743
+ /** Shared agent-loop scheduler defaults.
744
+ * @module dsh-agent-loop/constants
745
+ */
746
+ /** Default maximum in-flight parallel-safe calls per agent step. */
747
+ const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10;
748
+ //#endregion
749
+ //#region lib/types/index.js
750
+ /**
751
+ * Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them
752
+ * through the agent/session registries, and owns their ordered teardown.
753
+ *
754
+ * @module @stackstackstack/dsh-agent-loop
755
+ */
756
+ var __addDisposableResource = function(env, value, async) {
757
+ if (value !== null && value !== void 0) {
758
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
759
+ var dispose, inner;
760
+ if (async) {
761
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
762
+ dispose = value[Symbol.asyncDispose];
763
+ }
764
+ if (dispose === void 0) {
765
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
766
+ dispose = value[Symbol.dispose];
767
+ if (async) inner = dispose;
768
+ }
769
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
770
+ if (inner) dispose = function() {
771
+ try {
772
+ inner.call(this);
773
+ } catch (e) {
774
+ return Promise.reject(e);
775
+ }
776
+ };
777
+ env.stack.push({
778
+ value,
779
+ dispose,
780
+ async
781
+ });
782
+ } else if (async) env.stack.push({ async: true });
783
+ return value;
784
+ };
785
+ var __disposeResources = (function(SuppressedError) {
786
+ return function(env) {
787
+ function fail(e) {
788
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
789
+ env.hasError = true;
790
+ }
791
+ var r, s = 0;
792
+ function next() {
793
+ while (r = env.stack.pop()) try {
794
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
795
+ if (r.dispose) {
796
+ var result = r.dispose.call(r.value);
797
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
798
+ fail(e);
799
+ return next();
800
+ });
801
+ } else s |= 1;
802
+ } catch (e) {
803
+ fail(e);
804
+ }
805
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
806
+ if (env.hasError) throw env.error;
807
+ }
808
+ return next();
809
+ };
810
+ })(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
811
+ var e = new Error(message);
812
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
813
+ });
814
+ /** Fiber states that cannot own or serve a new lifecycle. */
815
+ const INACTIVE_STATES = new Set([
816
+ 5,
817
+ 4,
818
+ 3
819
+ ]);
820
+ /** Factory-level ownership: live agent teardowns plus config startup work. */
821
+ var FactoryOwnership = class {
822
+ fiber;
823
+ accepting = true;
824
+ teardown = new AbortController();
825
+ inactive = Promise.withResolvers();
826
+ liveAgents = /* @__PURE__ */ new Set();
827
+ startupTasks = /* @__PURE__ */ new Set();
828
+ constructor(fiber) {
829
+ this.fiber = fiber;
830
+ }
831
+ /** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */
832
+ get signal() {
833
+ return this.teardown.signal;
834
+ }
835
+ isActive() {
836
+ return this.accepting && !INACTIVE_STATES.has(this.fiber.state);
837
+ }
838
+ /** Track one live agent's shared teardown until it has run. */
839
+ track(dispose) {
840
+ this.liveAgents.add(dispose);
841
+ return () => {
842
+ this.liveAgents.delete(dispose);
843
+ };
844
+ }
845
+ /** Join config startup work that begins before an agent exists. */
846
+ trackStartup(job) {
847
+ this.startupTasks.add(job);
848
+ const forget = () => {
849
+ this.startupTasks.delete(job);
850
+ };
851
+ job.then(forget, forget);
852
+ }
853
+ /** Join one public create/resume continuation; factory dispose awaits its settlement. */
854
+ trackWrapper(job) {
855
+ this.trackStartup(job.then(() => void 0, () => void 0));
856
+ }
857
+ /** Resolve `task`, or stop waiting when factory teardown begins. */
858
+ async waitWhileActive(job) {
859
+ await Promise.race([job, this.inactive.promise]);
860
+ }
861
+ async dispose() {
862
+ this.accepting = false;
863
+ this.teardown.abort(/* @__PURE__ */ new Error("agent loop is not active"));
864
+ this.inactive.resolve();
865
+ await Promise.all([...[...this.liveAgents].map((dispose) => dispose()), ...this.startupTasks]);
866
+ }
867
+ };
868
+ /** Await `operation`, or throw the signal's reason as soon as it aborts. */
869
+ async function raceAbort(operation, signal, id) {
870
+ const toAbortError = () => signal.reason instanceof Error ? signal.reason : new Error(`agent "${id}" creation aborted`, { cause: signal.reason });
871
+ if (signal.aborted) throw toAbortError();
872
+ const aborted = Promise.withResolvers();
873
+ const listener = () => {
874
+ aborted.reject(toAbortError());
875
+ };
876
+ signal.addEventListener("abort", listener, { once: true });
877
+ try {
878
+ return await Promise.race([Promise.resolve(operation), aborted.promise]);
879
+ } finally {
880
+ signal.removeEventListener("abort", listener);
881
+ }
882
+ }
883
+ /** Start an abortable operation and release a value that arrives after cancellation. */
884
+ async function raceAbortCall(operation, signal, id, releaseAbandoned) {
885
+ if (signal.aborted) throw signal.reason instanceof Error ? signal.reason : new Error(`agent "${id}" creation aborted`, { cause: signal.reason });
886
+ const pending = Promise.resolve().then(operation);
887
+ try {
888
+ return await raceAbort(pending, signal, id);
889
+ } catch (error) {
890
+ if (signal.aborted && releaseAbandoned !== void 0) pending.then(releaseAbandoned, () => void 0);
891
+ throw error;
892
+ }
893
+ }
894
+ /** Resolve the deployment-wide scheduler cap at the owning config boundary. */
895
+ function resolveMaxParallelToolCalls(value) {
896
+ const maxParallelToolCalls = value ?? 10;
897
+ if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) throw new Error("maxParallelToolCalls must be a positive integer");
898
+ return maxParallelToolCalls;
899
+ }
900
+ /** Reject an output-token cap that cannot be represented exactly on the request wire. */
901
+ function assertAgentOptions(options) {
902
+ if (options.maxTokens !== void 0 && (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) throw new TypeError("agent maxTokens must be a positive safe integer");
903
+ }
904
+ /**
905
+ * Context key a launcher sets before any Loader entry mounts
906
+ * (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
907
+ * configured agents' session identities without a config key, so an overlay
908
+ * repointing the row's model route cannot drop them.
909
+ */
910
+ const CONFIGURED_AGENT_IDENTITIES_KEY = "configuredAgentIdentities";
911
+ /**
912
+ * Apply launcher-owned identities over the configured agents, replacing both
913
+ * identity keys for every entry the launcher named so a config-supplied
914
+ * identity can never survive alongside a launcher-supplied one.
915
+ * @param agents - the configured agent entries.
916
+ * @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
917
+ * @returns the entries with launcher-owned identities applied.
918
+ */
919
+ function applyLauncherIdentities(agents, identities) {
920
+ if (identities === void 0) return agents;
921
+ return agents.map((agent) => {
922
+ const identity = identities[agent.id];
923
+ if (identity === void 0) return agent;
924
+ const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent;
925
+ return identity.resume ? {
926
+ ...rest,
927
+ resumeSessionId: identity.id
928
+ } : {
929
+ ...rest,
930
+ sessionId: identity.id
931
+ };
932
+ });
933
+ }
934
+ /** Settings namespace carrying the tool-call parallelism a user owns. */
935
+ const AGENT_LOOP_SETTINGS_NAMESPACE = settingsNamespace("agent-loop");
936
+ /** Schema of the agent-loop settings section. */
937
+ const AGENT_LOOP_SETTINGS_SCHEMA = z.object({ maxParallelToolCalls: z.number().step(1).min(1).default(10) });
938
+ /** Reject self-contained identity conflicts before any configured agent starts. */
939
+ function validateConfiguredAgents(agents) {
940
+ const exactIdentities = /* @__PURE__ */ new Map();
941
+ for (const { id, sessionId, resumeSessionId } of agents) {
942
+ const hasResumeId = resumeSessionId !== void 0 && resumeSessionId !== "";
943
+ if (sessionId !== void 0 && hasResumeId) throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`);
944
+ const exactIdentity = hasResumeId ? resumeSessionId : sessionId;
945
+ if (exactIdentity === void 0) continue;
946
+ const firstId = exactIdentities.get(exactIdentity);
947
+ if (firstId !== void 0) throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`);
948
+ exactIdentities.set(exactIdentity, id);
949
+ }
950
+ }
951
+ /** Concrete agent factory and driver service. */
952
+ var AgentLoop = class extends Service {
953
+ static inject = [
954
+ "agents",
955
+ "sessions",
956
+ "llm",
957
+ "tools",
958
+ "systemPrompt"
959
+ ];
960
+ /** Runtime schema for declarative agents. */
961
+ static Config = z.object({
962
+ maxParallelToolCalls: z.number().step(1).min(1).default(10),
963
+ agents: z.array(z.object({
964
+ id: z.string().required(),
965
+ sessionId: z.string().min(1),
966
+ provider: z.string(),
967
+ model: z.string(),
968
+ maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
969
+ cwd: z.string(),
970
+ resumeSessionId: z.string()
971
+ })).default([])
972
+ });
973
+ /** Validated configuration owned by the agent-loop service. */
974
+ config;
975
+ ownership;
976
+ /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
977
+ runtime;
978
+ constructor(ctx, config) {
979
+ super(ctx, "agentLoop");
980
+ const entry = { maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls) };
981
+ let source = () => entry;
982
+ this.config = {
983
+ ...config,
984
+ agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
985
+ get maxParallelToolCalls() {
986
+ return source().maxParallelToolCalls;
987
+ }
988
+ };
989
+ installSettingsSection(ctx, AGENT_LOOP_SETTINGS_NAMESPACE, AGENT_LOOP_SETTINGS_SCHEMA, entry, {
990
+ validate: (value) => void resolveMaxParallelToolCalls(value.maxParallelToolCalls),
991
+ setSource: (current) => {
992
+ source = current;
993
+ },
994
+ onChange: () => {}
995
+ });
996
+ validateConfiguredAgents(this.config.agents);
997
+ this.ownership = new FactoryOwnership(ctx.fiber);
998
+ this.runtime = { ctx };
999
+ ctx.effect(() => () => this.ownership.dispose(), "agentLoop.transactions()");
1000
+ ctx.effect(() => ctx.agents.setFactory(this), "agentLoop.setFactory()");
1001
+ ctx.systemPrompt.variable("provider", (context) => context.agent?.options.provider);
1002
+ ctx.systemPrompt.variable("model", (context) => context.agent?.options.model);
1003
+ ctx.systemPrompt.variable("cwd", (context) => context.agent?.session.header.cwd);
1004
+ for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
1005
+ const meta = cwd === void 0 ? {} : { cwd };
1006
+ if (resumeSessionId === void 0 || resumeSessionId === "") {
1007
+ const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`);
1008
+ const persistence = sessionId === void 0 ? void 0 : ctx.get("sessionPersistence");
1009
+ if (persistence === void 0) this.create(configuredId, options, meta);
1010
+ else {
1011
+ const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error) => {
1012
+ this.reportConfiguredStartupFailure(id, "restore", configuredId, error);
1013
+ });
1014
+ this.ownership.trackStartup(startup);
1015
+ }
1016
+ continue;
1017
+ }
1018
+ ctx.effect(() => {
1019
+ return ctx.inject(["sessionPersistence"], (childCtx) => {
1020
+ this.resumeWith(ctx, childCtx.sessionPersistence, {
1021
+ resumeSessionId,
1022
+ agentOptions: options
1023
+ }).catch((error) => {
1024
+ this.reportConfiguredStartupFailure(id, "resume", resumeSessionId, error);
1025
+ });
1026
+ }).dispose;
1027
+ }, `agentLoop.resume(${id})`);
1028
+ }
1029
+ }
1030
+ /** Report a contained declarative-start failure to identity-bound consumers. */
1031
+ reportConfiguredStartupFailure(configId, action, sessionId, error) {
1032
+ if (!this.ownership.isActive()) return;
1033
+ this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`);
1034
+ const args = ["agent-loop/config-start-failed", {
1035
+ sessionId,
1036
+ error
1037
+ }];
1038
+ for (const callback of this.ctx.events.dispatch("emit", args)) try {
1039
+ const returned = callback(...args);
1040
+ Promise.resolve(returned).catch((listenerError) => {
1041
+ this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`);
1042
+ });
1043
+ } catch (listenerError) {
1044
+ this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`);
1045
+ }
1046
+ }
1047
+ /** Restore a materialized exact config identity on remount, or create it on first use. */
1048
+ async restoreOrCreateConfigured(ownerCtx, persistence, sessionId, agentOptions, meta) {
1049
+ await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId);
1050
+ if (!this.ownership.isActive()) return;
1051
+ try {
1052
+ await this.resumeWith(ownerCtx, persistence, {
1053
+ resumeSessionId: sessionId,
1054
+ agentOptions
1055
+ });
1056
+ return;
1057
+ } catch (error) {
1058
+ if (!this.ownership.isActive()) return;
1059
+ if ((await persistence.list()).some((header) => header.id === sessionId)) throw error;
1060
+ }
1061
+ this.create(sessionId, agentOptions, meta);
1062
+ }
1063
+ /** Wait for a draining same-id lifecycle to finish registry teardown. */
1064
+ async waitForDrainingConfiguredIdentity(ownerCtx, sessionId) {
1065
+ if (ownerCtx.agents.get(sessionId) === void 0 && ownerCtx.sessions.get(sessionId) === void 0) return;
1066
+ const released = Promise.withResolvers();
1067
+ const checkReleased = () => {
1068
+ if (ownerCtx.agents.get(sessionId) === void 0 && ownerCtx.sessions.get(sessionId) === void 0) released.resolve();
1069
+ };
1070
+ const disposeAgentListener = ownerCtx.on("agent/disposed", () => {
1071
+ checkReleased();
1072
+ });
1073
+ const disposeSessionListener = ownerCtx.on("session/disposed", checkReleased);
1074
+ try {
1075
+ checkReleased();
1076
+ await this.ownership.waitWhileActive(released.promise);
1077
+ } finally {
1078
+ disposeAgentListener();
1079
+ disposeSessionListener();
1080
+ }
1081
+ }
1082
+ /**
1083
+ * Construct the driver, scope, and one memoized reverse teardown for a new
1084
+ * agent. The teardown is registered with the factory and the owner fiber
1085
+ * BEFORE publication, so a mid-setup unload rolls everything back; `signal`
1086
+ * fuses caller cancellation with lifecycle teardown for setup awaits.
1087
+ */
1088
+ prepare(ownerCtx, id, options, session, callerSignal) {
1089
+ assertAgentOptions(options);
1090
+ ownerCtx.fiber.assertActive();
1091
+ /* v8 ignore next -- unreachable backstop, see above */
1092
+ if (!this.ownership.isActive()) throw new Error("agent loop is not active");
1093
+ if (callerSignal?.aborted) throw callerSignal.reason instanceof Error ? callerSignal.reason : new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason });
1094
+ const loopCtx = this.runtime.ctx;
1095
+ const abort = new AbortController();
1096
+ const onCallerAbort = () => {
1097
+ abort.abort(callerSignal?.reason instanceof Error ? callerSignal.reason : new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason }));
1098
+ };
1099
+ const onFactoryTeardown = () => {
1100
+ abort.abort(this.ownership.signal.reason);
1101
+ };
1102
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
1103
+ this.ownership.signal.addEventListener("abort", onFactoryTeardown, { once: true });
1104
+ let machine;
1105
+ let detachSession;
1106
+ let detachAgent;
1107
+ let disposing;
1108
+ const machineReady = Promise.withResolvers();
1109
+ const dispose = (ownerTriggered = false) => disposing ??= (async () => {
1110
+ abort.abort(/* @__PURE__ */ new Error(`agent "${id}" lifecycle disposed`));
1111
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1112
+ this.ownership.signal.removeEventListener("abort", onFactoryTeardown);
1113
+ try {
1114
+ if (machine === void 0) await machineReady.promise;
1115
+ if (machine !== void 0) {
1116
+ machine.cancel({ kind: "disposed" });
1117
+ await machine.whenIdle();
1118
+ await machine.scope.dispose();
1119
+ }
1120
+ } finally {
1121
+ try {
1122
+ detachAgent?.();
1123
+ detachSession?.();
1124
+ } finally {
1125
+ untrack();
1126
+ if (!ownerTriggered) await unfollowOwner();
1127
+ }
1128
+ }
1129
+ })();
1130
+ const untrack = this.ownership.track(dispose);
1131
+ let unfollowOwner;
1132
+ try {
1133
+ unfollowOwner = ownerCtx.effect(() => () => {
1134
+ if (disposing !== void 0) return;
1135
+ abort.abort(/* @__PURE__ */ new Error(`agent "${id}" setup aborted: owner disposed during setup`));
1136
+ return dispose(true);
1137
+ }, `agentLoop.lifecycle(${id})`);
1138
+ } catch (error) {
1139
+ untrack();
1140
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1141
+ this.ownership.signal.removeEventListener("abort", onFactoryTeardown);
1142
+ throw error;
1143
+ }
1144
+ /* v8 ignore stop */
1145
+ const assertLive = () => {
1146
+ if (!abort.signal.aborted) return;
1147
+ /* v8 ignore next -- unreachable String() arm, see above */
1148
+ throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason));
1149
+ };
1150
+ try {
1151
+ const agent = machine = new ReactLoopAgent(loopCtx, id, options, session);
1152
+ machineReady.resolve();
1153
+ assertLive();
1154
+ return {
1155
+ agent,
1156
+ signal: abort.signal,
1157
+ publish: (source) => {
1158
+ assertLive();
1159
+ detachSession = agent.ctx.sessions.enter(session);
1160
+ detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent);
1161
+ agent.ctx.sessions.announce(session);
1162
+ assertLive();
1163
+ loopCtx.agents.announce(agent);
1164
+ assertLive();
1165
+ emitAgentEvent(loopCtx, agent, "agent/session-start", { source });
1166
+ assertLive();
1167
+ return {
1168
+ agent,
1169
+ dispose
1170
+ };
1171
+ },
1172
+ dispose
1173
+ };
1174
+ } catch (error) {
1175
+ machineReady.resolve();
1176
+ dispose();
1177
+ throw error;
1178
+ }
1179
+ }
1180
+ /**
1181
+ * Create an agent and session under one caller-supplied identity, owned by
1182
+ * the accessing fiber. Constructor-driven config calls mint a fresh combined
1183
+ * id before entering this boundary.
1184
+ * @param id - shared agent/session identity.
1185
+ * @param options - concrete loop options.
1186
+ * @param meta - optional fresh-session workspace metadata.
1187
+ * @returns the published running agent.
1188
+ */
1189
+ create(id, options = {}, meta = {}) {
1190
+ const env_1 = {
1191
+ stack: [],
1192
+ error: void 0,
1193
+ hasError: false
1194
+ };
1195
+ try {
1196
+ const preparation = __addDisposableResource(env_1, SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta })), false);
1197
+ const prepared = this.prepare(this.ctx, id, options, preparation.session);
1198
+ try {
1199
+ return prepared.publish("startup").agent;
1200
+ } catch (error) {
1201
+ prepared.dispose();
1202
+ throw error;
1203
+ }
1204
+ } catch (e_1) {
1205
+ env_1.error = e_1;
1206
+ env_1.hasError = true;
1207
+ } finally {
1208
+ __disposeResources(env_1);
1209
+ }
1210
+ }
1211
+ /**
1212
+ * Create an owned agent on a caller-supplied session id.
1213
+ * @param ownerCtx - caller context that structurally owns the lifecycle.
1214
+ * @param options - identities, session seed/metadata, loop options, setup, and cancellation.
1215
+ * @returns the published handle.
1216
+ */
1217
+ async createAgent(ownerCtx, options) {
1218
+ const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
1219
+ ...options.seed === void 0 ? {} : { seed: options.seed },
1220
+ ...options.meta === void 0 ? {} : { meta: options.meta }
1221
+ }));
1222
+ const published = this.setupAndPublish(ownerCtx, options.sessionId, preparation, options.agentOptions ?? {}, options.setup, options.signal, "startup");
1223
+ this.ownership.trackWrapper(published);
1224
+ return published;
1225
+ }
1226
+ /** Prepare one Agent around an acquired Session, run setup, and publish it. */
1227
+ async setupAndPublish(ownerCtx, id, preparation, agentOptions, setup, signal, source) {
1228
+ const env_2 = {
1229
+ stack: [],
1230
+ error: void 0,
1231
+ hasError: false
1232
+ };
1233
+ try {
1234
+ const session = __addDisposableResource(env_2, preparation, false).session;
1235
+ const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal);
1236
+ try {
1237
+ (await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id))?.commit();
1238
+ return prepared.publish(source);
1239
+ } catch (error) {
1240
+ await prepared.dispose();
1241
+ throw error;
1242
+ }
1243
+ } catch (e_2) {
1244
+ env_2.error = e_2;
1245
+ env_2.hasError = true;
1246
+ } finally {
1247
+ __disposeResources(env_2);
1248
+ }
1249
+ }
1250
+ /**
1251
+ * Resume an owned agent from the configured persistence service.
1252
+ * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
1253
+ * @param options - persisted identity, loop options, setup, and cancellation.
1254
+ * @returns the published handle.
1255
+ */
1256
+ async resume(ownerCtx, options) {
1257
+ const persistence = this.runtime.ctx.get("sessionPersistence");
1258
+ if (persistence === void 0) throw new Error("cannot resume: session persistence is not configured (load a dsh-session-persistence backend)");
1259
+ return this.resumeWith(ownerCtx, persistence, options);
1260
+ }
1261
+ /** Resume through an explicit persistence handle used by the deferred config path. */
1262
+ resumeWith(ownerCtx, persistence, options) {
1263
+ const id = options.resumeSessionId;
1264
+ const published = (async () => {
1265
+ const ownerAbort = new AbortController();
1266
+ const unfollowOwner = ownerCtx.effect(() => () => {
1267
+ ownerAbort.abort(/* @__PURE__ */ new Error(`agent "${id}" setup aborted: owner disposed during setup`));
1268
+ }, `agentLoop.resume-load(${id})`);
1269
+ const fused = AbortSignal.any([
1270
+ ...options.signal === void 0 ? [] : [options.signal],
1271
+ ownerAbort.signal,
1272
+ this.ownership.signal
1273
+ ]);
1274
+ let preparation;
1275
+ try {
1276
+ try {
1277
+ preparation = await raceAbortCall(() => persistence.prepare(id, fused), fused, id, (abandoned) => {
1278
+ abandoned[Symbol.dispose]();
1279
+ });
1280
+ } finally {
1281
+ await unfollowOwner();
1282
+ }
1283
+ ownerCtx.fiber.assertActive();
1284
+ if (!this.ownership.isActive()) throw new Error("agent loop is not active");
1285
+ return await this.setupAndPublish(ownerCtx, id, preparation, options.agentOptions ?? {}, options.setup, options.signal, "resume");
1286
+ } finally {
1287
+ preparation?.[Symbol.dispose]();
1288
+ }
1289
+ })();
1290
+ this.ownership.trackWrapper(published);
1291
+ return published;
1292
+ }
1293
+ };
1294
+ //#endregion
1295
+ export { AGENT_LOOP_SETTINGS_NAMESPACE, AGENT_LOOP_SETTINGS_SCHEMA, AgentLoop, AgentLoop as default, CONFIGURED_AGENT_IDENTITIES_KEY, DEFAULT_MAX_PARALLEL_TOOL_CALLS };