@civilization/dsh-agent-loop 0.0.0-stage → 0.2.1-rc.3

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,2032 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import { randomUUID } from "node:crypto";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { z as z$1 } from "zod";
5
+ import { brandString } from "@deepseek-ai/dsh-brand";
6
+ import { AssistantStreamAccumulator, BlockAssembler, LlmAttemptId, LlmError, createAssistantMessage, createDeveloperMessage, createSystemMessage, createToolResultMessage, createUserMessage, errorChain, markAgentLoopRequest } from "@deepseek-ai/dsh-llm";
7
+ import { SessionLogOffset, SessionPreparation, SessionSeq, ToolCallRecovery, canonicalHeader, headerEquals, interruptedTurnClosers, isReplacementSurfaceEvent } from "@deepseek-ai/dsh-session";
8
+ import { SessionPersistenceNotFoundError } from "@deepseek-ai/dsh-session-persistence";
9
+ import { agentEvents, assembleContextFor } from "@deepseek-ai/dsh-agent";
10
+ import { assertNever, deepFreeze } from "@deepseek-ai/dsh-util-values";
11
+ import { createScope } from "@deepseek-ai/dsh-scope";
12
+ import { joinContextSections, renderContextSections, renderPrompt } from "@deepseek-ai/dsh-system-prompt";
13
+ import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER } from "@deepseek-ai/dsh-tools";
14
+ //#region lib/types/inbox.js
15
+ /**
16
+ * Driver-owned durable agent inbox projection and command facade.
17
+ *
18
+ * @module @deepseek-ai/dsh-agent-loop/inbox
19
+ */
20
+ /** Wire validation for pending agent input reconstructed from durable inbox splices. */
21
+ const inboxProjectionSchema = z$1.object({
22
+ "next-turn": z$1.array(z$1.custom()).readonly(),
23
+ "next-step": z$1.array(z$1.custom()).readonly()
24
+ }).readonly();
25
+ /** Standard fold that reconstructs pending input and rejects invalid durable splice history. */
26
+ const inboxProjectionDefinition = {
27
+ key: "inbox",
28
+ stateSchema: inboxProjectionSchema,
29
+ init: () => ({
30
+ "next-turn": [],
31
+ "next-step": []
32
+ }),
33
+ apply(state, event) {
34
+ if (event.type !== "agent/inbox/spliced") return state;
35
+ const splice = event.data;
36
+ try {
37
+ const inbox = state[splice.target];
38
+ const removedCount = splice.removedCount ?? 0;
39
+ if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length || !Number.isSafeInteger(removedCount) || removedCount < 0 || splice.start + removedCount > inbox.length) throw new Error("invalid inbox splice");
40
+ const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted);
41
+ const ids = /* @__PURE__ */ new Set();
42
+ for (const message of splice.target === "next-turn" ? [...next, ...state["next-step"]] : [...state["next-turn"], ...next]) {
43
+ if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`);
44
+ ids.add(message.id);
45
+ }
46
+ return splice.target === "next-turn" ? {
47
+ "next-turn": next,
48
+ "next-step": state["next-step"]
49
+ } : {
50
+ "next-turn": state["next-turn"],
51
+ "next-step": next
52
+ };
53
+ } catch (error) {
54
+ throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error });
55
+ }
56
+ },
57
+ wire: {
58
+ viewSchema: inboxProjectionSchema,
59
+ view: (state) => state
60
+ },
61
+ stateVersion: 1
62
+ };
63
+ /**
64
+ * Driver-owned durable Inbox implementation used by ReactLoopAgent and focused
65
+ * provider tests.
66
+ * @param projections - registry with the standard Inbox projection registered by AgentLoop.
67
+ * @param session - session whose durable events store pending input.
68
+ * @param dispatch - agent-scoped notifications for Inbox lifecycle events.
69
+ */
70
+ var ReactLoopInbox = class {
71
+ projections;
72
+ session;
73
+ dispatch;
74
+ constructor(projections, session, dispatch) {
75
+ this.projections = projections;
76
+ this.session = session;
77
+ this.dispatch = dispatch;
78
+ }
79
+ /** Prompts awaiting individual turns. */
80
+ get nextTurn() {
81
+ return this.current()["next-turn"];
82
+ }
83
+ /** Input awaiting the next step boundary. */
84
+ get nextStep() {
85
+ return this.current()["next-step"];
86
+ }
87
+ /** Whether either pending-message list contains work. */
88
+ get hasPending() {
89
+ const state = this.current();
90
+ return state["next-turn"].length > 0 || state["next-step"].length > 0;
91
+ }
92
+ /** Durably cancel all pending input, clearing next-step before next-turn. */
93
+ clear() {
94
+ this.splice("next-step", 0, this.nextStep.length, []);
95
+ this.splice("next-turn", 0, this.nextTurn.length, []);
96
+ }
97
+ /**
98
+ * Remove and return the complete batch proposed for one step.
99
+ * @param target - whether this boundary also consumes one queued turn.
100
+ * @param turn - turn that will own the claimed batch.
101
+ * @returns next-step input followed by the queued turn, when requested.
102
+ */
103
+ claim(target, turn) {
104
+ const claimed = this.mutate("next-step", 0, this.nextStep.length, [], false);
105
+ if (target === "next-turn") claimed.push(...this.mutate("next-turn", 0, 1, [], false));
106
+ for (const message of claimed) this.dispatch.emit("agent/inbox/claimed", {
107
+ message,
108
+ turn
109
+ });
110
+ return claimed;
111
+ }
112
+ /**
113
+ * Append one message to a pending list.
114
+ * @param target - pending list to extend.
115
+ * @param message - message to append.
116
+ */
117
+ append(target, message) {
118
+ this.splice(target, this.current()[target].length, 0, [message]);
119
+ }
120
+ /**
121
+ * Prepend one message to a pending list.
122
+ * @param target - pending list to extend.
123
+ * @param message - message to prepend.
124
+ */
125
+ prepend(target, message) {
126
+ this.splice(target, 0, 0, [message]);
127
+ }
128
+ /**
129
+ * Replace one pending message in place.
130
+ * @param messageId - identity of the pending message to replace.
131
+ * @param newMessage - replacement message.
132
+ * @returns whether the message was still pending.
133
+ */
134
+ replace(messageId, newMessage) {
135
+ const location = this.locate(messageId);
136
+ if (location === void 0) return false;
137
+ this.splice(location.target, location.index, 1, [newMessage]);
138
+ return true;
139
+ }
140
+ /**
141
+ * Remove one pending message.
142
+ * @param messageId - identity of the pending message to remove.
143
+ * @returns whether the message was still pending.
144
+ */
145
+ remove(messageId) {
146
+ const location = this.locate(messageId);
147
+ if (location === void 0) return false;
148
+ this.splice(location.target, location.index, 1, []);
149
+ return true;
150
+ }
151
+ /**
152
+ * Apply standard splice semantics and durably record the normalized result.
153
+ * @param target - pending list to mutate.
154
+ * @param start - splice position.
155
+ * @param deleteCount - maximum number of messages to remove.
156
+ * @param inserted - messages to insert at the resolved position.
157
+ * @returns messages removed by the splice.
158
+ */
159
+ splice(target, start, deleteCount, inserted) {
160
+ return this.mutate(target, start, deleteCount, inserted, true);
161
+ }
162
+ /** Locate one pending identity across both owned lists. */
163
+ locate(messageId) {
164
+ const state = this.current();
165
+ for (const target of ["next-turn", "next-step"]) {
166
+ const index = state[target].findIndex((message) => message.id === messageId);
167
+ if (index >= 0) return {
168
+ target,
169
+ index
170
+ };
171
+ }
172
+ }
173
+ /** Read the current durable projection state. */
174
+ current() {
175
+ const state = this.projections.stateOf(this.session, "inbox");
176
+ if (state === void 0) throw new Error(`agent "${this.session.id}" cannot read inbox state: its projection registration is not active`);
177
+ return state;
178
+ }
179
+ /** Commit one normalized mutation and publish its live events. */
180
+ mutate(target, start, deleteCount, inserted, discardRemoved) {
181
+ const state = this.current();
182
+ const inbox = state[target];
183
+ const truncatedStart = Math.trunc(start);
184
+ const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart;
185
+ const actualStart = offset < 0 ? Math.max(inbox.length + offset, 0) : Math.min(offset, inbox.length);
186
+ const truncatedDeleteCount = Math.trunc(deleteCount);
187
+ const actualDeleteCount = Math.min(Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0), inbox.length - actualStart);
188
+ if (actualDeleteCount === 0 && inserted.length === 0) return [];
189
+ const candidate = inbox.toSpliced(actualStart, actualDeleteCount, ...inserted);
190
+ const ids = /* @__PURE__ */ new Set();
191
+ for (const message of target === "next-turn" ? [...candidate, ...state["next-step"]] : [...state["next-turn"], ...candidate]) {
192
+ if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`);
193
+ ids.add(message.id);
194
+ }
195
+ const outcome = discardRemoved && actualDeleteCount > 0 ? "canceled" : void 0;
196
+ const splice = {
197
+ target,
198
+ start: actualStart,
199
+ ...actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount },
200
+ inserted,
201
+ ...outcome === void 0 ? {} : { outcome }
202
+ };
203
+ const removed = inbox.slice(actualStart, actualStart + actualDeleteCount);
204
+ const event = this.session.append("agent/inbox/spliced", splice);
205
+ if (discardRemoved) for (const message of removed) this.dispatch.emit("agent/inbox/discarded", { message });
206
+ for (const message of event.data.inserted) this.dispatch.emit("agent/inbox/inserted", { message });
207
+ return removed;
208
+ }
209
+ };
210
+ //#endregion
211
+ //#region lib/types/runtime-context.js
212
+ /**
213
+ * Durable projection state for the two loop-owned surface messages the system
214
+ * prompt plugin forms: the system prompt (surface node 0 and any in-history
215
+ * replacement) and the dynamic runtime-context snapshot.
216
+ * @module @deepseek-ai/dsh-agent-loop/runtime-context
217
+ */
218
+ const SOURCE = "runtime-context";
219
+ const CLEARED = "Current runtime context: none. Earlier runtime-context snapshots no longer apply.";
220
+ function isOwned(message) {
221
+ return message.source.kind === SOURCE;
222
+ }
223
+ function textOf(message) {
224
+ const [block] = message.content;
225
+ return message.content.length === 1 && block?.type === "text" ? block.text : void 0;
226
+ }
227
+ /** Committed events from the newest backward; the restore scans stop at the first match. */
228
+ function eventsNewestFirst(session) {
229
+ return session.snapshotEvents().toReversed();
230
+ }
231
+ /**
232
+ * Decides how a rendered system prompt reaches the surface without owning the
233
+ * commit. The first prompt, even empty, reserves surface node 0.
234
+ * A capable continuing series appends changed nonempty text after the
235
+ * cached history. An incapable route, broken series, or cleared prompt instead
236
+ * normalizes the first system node and empties later active nodes. Dormant empty
237
+ * tails do not supply effective text or require repeated replacements.
238
+ */
239
+ var SystemPromptProjection = class {
240
+ session;
241
+ constructor(session) {
242
+ this.session = session;
243
+ }
244
+ /** The surviving `system/message` nodes in surface order. */
245
+ systemNodes() {
246
+ const nodes = [];
247
+ for (const seq of this.session.surface.nodes) {
248
+ const event = this.session.eventAt(seq);
249
+ if (event?.type !== "system/message") continue;
250
+ const text = event.data.message.content.length === 0 ? "" : textOf(event.data.message);
251
+ nodes.push({
252
+ seq,
253
+ text
254
+ });
255
+ }
256
+ return nodes;
257
+ }
258
+ /**
259
+ * Reconcile effective text and retained nodes with the prepared route and series.
260
+ * @param rendered - the fully rendered system prompt; `''` when none is active.
261
+ * @param input - the route capability and series facts for this step.
262
+ * @returns ordered per-node updates; an empty list means no update is needed.
263
+ */
264
+ project(rendered, input) {
265
+ const nodes = this.systemNodes();
266
+ const head = nodes[0];
267
+ if (head === void 0) return [{
268
+ message: createSystemMessage(rendered),
269
+ intent: { surfaceOp: "append" }
270
+ }];
271
+ const latest = nodes.findLast((node) => node.text !== "") ?? head;
272
+ if (!input.inHistory || input.startsSeries || rendered.length === 0) {
273
+ const updates = nodes.slice(1).filter((node) => node.text !== "").map((node) => this.replace(node.seq, ""));
274
+ if (head.text !== rendered) updates.push(this.replace(head.seq, rendered));
275
+ return updates;
276
+ }
277
+ if (latest.text === rendered) return [];
278
+ return [{
279
+ message: createSystemMessage(rendered),
280
+ intent: { surfaceOp: "append" }
281
+ }];
282
+ }
283
+ replace(seq, text) {
284
+ return {
285
+ message: createSystemMessage(text),
286
+ intent: {
287
+ surfaceOp: {
288
+ op: "replace",
289
+ startSeq: seq,
290
+ endSeq: seq
291
+ },
292
+ sourceEventSeqs: [seq]
293
+ }
294
+ };
295
+ }
296
+ };
297
+ /** Tracks the last retained runtime-context snapshot without owning its commit. */
298
+ var RuntimeContextProjection = class {
299
+ /** `undefined` means no snapshot ever existed; `null` means none is retained. */
300
+ retained;
301
+ /**
302
+ * Restore projection state once, then follow authoritative session events.
303
+ * @param ctx - agent-scoped event context.
304
+ * @param session - session receiving projected messages.
305
+ */
306
+ constructor(ctx, session) {
307
+ const surface = new Set(session.surface.nodes);
308
+ for (const event of eventsNewestFirst(session)) {
309
+ if (event.type !== "user/message" || !isOwned(event.data)) continue;
310
+ this.retained ??= null;
311
+ if (surface.has(event.seq)) {
312
+ this.retained = {
313
+ seq: event.seq,
314
+ text: textOf(event.data)
315
+ };
316
+ break;
317
+ }
318
+ }
319
+ ctx.on("session/event", (subject, event) => {
320
+ if (subject !== session) return;
321
+ if (event.type === "user/message" && isOwned(event.data)) this.retained = {
322
+ seq: event.seq,
323
+ text: textOf(event.data)
324
+ };
325
+ else if (this.retained && isReplacementSurfaceEvent(event) && event.sourceEventSeqs?.includes(this.retained.seq) === true) this.retained = null;
326
+ });
327
+ }
328
+ /**
329
+ * Create an uncommitted snapshot only when the retained value differs.
330
+ * @param current - fully rendered dynamic context.
331
+ * @param sections - named contributions that formed the current snapshot.
332
+ * @returns a candidate user message, or `undefined` when no update is needed.
333
+ */
334
+ project(current, sections) {
335
+ if (this.retained === void 0 && current.length === 0) return;
336
+ const snapshot = current.length === 0 ? CLEARED : current;
337
+ if (this.retained?.text === snapshot) return;
338
+ return createUserMessage({
339
+ content: [{
340
+ type: "text",
341
+ text: snapshot
342
+ }],
343
+ source: sections.length === 0 ? { kind: SOURCE } : {
344
+ kind: SOURCE,
345
+ form: "snapshot",
346
+ sections
347
+ }
348
+ });
349
+ }
350
+ };
351
+ //#endregion
352
+ //#region lib/types/assistant-stream.js
353
+ /** Process-local assistant attempt framing and durable stream accumulation. */
354
+ /** Folds one model attempt into one compact stream plus ordered transient frames. */
355
+ var AssistantStreamAttempt = class {
356
+ nextRevision;
357
+ turn;
358
+ step;
359
+ emit;
360
+ accumulator = new AssistantStreamAccumulator();
361
+ assembler = new BlockAssembler();
362
+ index = 0;
363
+ terminal = false;
364
+ /** Attempt identity unique within this Agent lifecycle. */
365
+ attemptId;
366
+ /** Whether this started attempt has emitted its terminal frame. */
367
+ get ended() {
368
+ return this.terminal;
369
+ }
370
+ /**
371
+ * @param sessionId - identity embedded only in the Agent-lifecycle-local attempt id.
372
+ * @param attempt - attached-Session-local attempt counter.
373
+ * @param nextRevision - allocates the next emitted frame revision.
374
+ * @param turn - durable turn owning the request.
375
+ * @param step - durable step owning the request.
376
+ * @param emit - agent-scoped notification publisher.
377
+ */
378
+ constructor(sessionId, attempt, nextRevision, turn, step, emit) {
379
+ this.nextRevision = nextRevision;
380
+ this.turn = turn;
381
+ this.step = step;
382
+ this.emit = emit;
383
+ this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`);
384
+ }
385
+ /** Publish the opening marker before the first delivered chunk. */
386
+ start() {
387
+ this.emit({
388
+ type: "start",
389
+ attemptId: this.attemptId,
390
+ revision: this.nextRevision(),
391
+ turn: this.turn,
392
+ step: this.step
393
+ });
394
+ }
395
+ /** Snapshot one chunk once, then feed durable compaction, assembly, and live publication. */
396
+ push(chunk) {
397
+ const timed = this.accumulator.push({
398
+ time: Date.now(),
399
+ chunk
400
+ });
401
+ this.assembler.push(timed.chunk);
402
+ this.emit({
403
+ type: "chunk",
404
+ attemptId: this.attemptId,
405
+ revision: this.nextRevision(),
406
+ index: this.index++,
407
+ time: timed.time,
408
+ chunk: timed.chunk
409
+ });
410
+ }
411
+ /**
412
+ * Publish terminal settlement after the matching durable event commits.
413
+ * @param eventType - durable settlement type.
414
+ * @param append - synchronous durable append returning its committed seq.
415
+ */
416
+ settle(eventType, append) {
417
+ let seq;
418
+ try {
419
+ seq = append();
420
+ } catch (error) {
421
+ this.abandon();
422
+ throw error;
423
+ }
424
+ this.terminal = true;
425
+ this.emit({
426
+ type: "end",
427
+ attemptId: this.attemptId,
428
+ revision: this.nextRevision(),
429
+ index: this.index,
430
+ outcome: {
431
+ kind: "committed",
432
+ eventType,
433
+ seq
434
+ }
435
+ });
436
+ }
437
+ /** Publish abandonment when no durable attempt event can be committed. */
438
+ abandon() {
439
+ this.terminal = true;
440
+ this.emit({
441
+ type: "end",
442
+ attemptId: this.attemptId,
443
+ revision: this.nextRevision(),
444
+ index: this.index,
445
+ outcome: { kind: "abandoned" }
446
+ });
447
+ }
448
+ /** Exact compact stream for the final durable event. */
449
+ get stream() {
450
+ return [...this.accumulator.snapshot()];
451
+ }
452
+ /** Canonical completed-message blocks from the same chunks. */
453
+ blocks() {
454
+ return this.assembler.blocks();
455
+ }
456
+ /** Safe visible prefix when cancellation interrupts the attempt. */
457
+ interruptedBlocks() {
458
+ return this.assembler.interruptedBlocks();
459
+ }
460
+ /** Latest adapter-reported usage in the stream. */
461
+ get usage() {
462
+ return this.assembler.usage;
463
+ }
464
+ /** Terminal reason, defaulting to stop when the stream omitted one. */
465
+ get finish() {
466
+ return this.assembler.finish;
467
+ }
468
+ /** Replay state carried by the terminal finish record. */
469
+ get replayState() {
470
+ return this.assembler.replayState;
471
+ }
472
+ };
473
+ //#endregion
474
+ //#region lib/types/tool-calls.js
475
+ /**
476
+ * Schedules one assistant step's tool calls. Exclusive calls form barriers;
477
+ * parallel calls use a bounded rolling pool and are reclassified before start.
478
+ * Dispatch may overlap, while policy, results, and result context remain
479
+ * model-ordered. Abort or an internal scheduler failure stops replenishment
480
+ * and drains started calls.
481
+ *
482
+ * Abort records synthetic error results for skipped calls so replay stays
483
+ * valid. A terminal scheduler failure rejects after draining; the owning step
484
+ * records conservative recovery results before closing.
485
+ * @module dsh-agent-loop/tool-calls
486
+ */
487
+ /**
488
+ * Schedule one assistant step's tool calls by their live concurrency mode.
489
+ * Ordinary completion and abort commit started-call results in order. Abort
490
+ * drains them, records synthetic results for unstarted calls, and returns with
491
+ * the signal still aborted after accepting started-call context through the
492
+ * caller-supplied acceptor (the machine stages it in its next-step inbox for the
493
+ * step boundary). An internal scheduler failure stops new dispatches, drains
494
+ * already-started dispatches, and rejects with the first failure. The owning
495
+ * step supplies error results for requests without a committed outcome.
496
+ * The committed step's AgentLoop driver boundary supplies the initiating Agent
497
+ * that becomes each explicit {@link ToolExecutionInput.agent}.
498
+ *
499
+ * @param ctx - loop context that owns the tool registry and carries the initiating Agent.
500
+ * @param turn - current turn number.
501
+ * @param step - current step number.
502
+ * @param toolCalls - assistant calls in model order.
503
+ * @param signal - abort signal shared by the step.
504
+ * @param acceptContext - accepts committed result context for the next step boundary.
505
+ */
506
+ async function executeToolCalls(ctx, turn, step, toolCalls, signal, acceptContext) {
507
+ const agent = ctx.agents.requireInitiator();
508
+ const { session } = agent;
509
+ const planned = toolCalls.map((block) => ({
510
+ block,
511
+ exec: {
512
+ callId: block.id,
513
+ name: block.name,
514
+ arguments: parseArguments(block.arguments),
515
+ agent,
516
+ signal
517
+ }
518
+ }));
519
+ let next = 0;
520
+ let concluded = false;
521
+ while (next < planned.length) {
522
+ const first = planned[next];
523
+ const mode = ctx.tools.executionMode(first.exec).kind;
524
+ const outcome = await runGroup(ctx, turn, step, mode === "parallel" ? planned.slice(next) : [first], mode, signal, acceptContext);
525
+ next += outcome.consumed;
526
+ concluded ||= outcome.concluded;
527
+ if (outcome.aborted) {
528
+ for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block);
529
+ return { concluded };
530
+ }
531
+ }
532
+ return { concluded };
533
+ }
534
+ /** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
535
+ function parseArguments(raw) {
536
+ try {
537
+ return raw ? JSON.parse(raw) : {};
538
+ } catch {
539
+ return raw;
540
+ }
541
+ }
542
+ function getToolRuntimeScheduler(ctx) {
543
+ const direct = ctx.tools[TOOL_RUNTIME_SCHEDULER];
544
+ if (direct !== void 0) return direct;
545
+ const key = Object.getOwnPropertySymbols(ctx.tools).find((candidate) => candidate.description === TOOL_RUNTIME_SCHEDULER.description);
546
+ const scheduler = key === void 0 ? void 0 : ctx.tools[key];
547
+ if (scheduler !== void 0 && typeof scheduler === "object" && scheduler !== null && "prepare" in scheduler && "dispatch" in scheduler && "finalize" in scheduler && "finish" in scheduler) return scheduler;
548
+ throw new Error("Tool runtime scheduler is missing. The profile may have loaded incompatible copies of @deepseek-ai/dsh-tools.");
549
+ }
550
+ /**
551
+ * Run one exclusive barrier or parallel pool. Later calls are reclassified
552
+ * before start; an exclusive reclassification waits for the current pool to
553
+ * drain and remains for the caller's next barrier. Results and contexts commit
554
+ * in model order. Abort stops starts, drains and commits started calls, accepts
555
+ * their contexts into the owning batch, records results for skipped calls, and
556
+ * returns an aborted outcome. Scheduler failure drains dispatches and rejects
557
+ * for the owning step to record recovery results.
558
+ */
559
+ async function runGroup(ctx, turn, step, group, mode, signal, acceptContext) {
560
+ const scheduler = getToolRuntimeScheduler(ctx);
561
+ const { session } = ctx.agents.requireInitiator();
562
+ const maxParallelToolCalls = ctx.agentLoop.config.maxParallelToolCalls.get();
563
+ const slots = group.map(() => void 0);
564
+ const callSeqs = group.map(() => void 0);
565
+ let nextToStart = 0;
566
+ let committed = 0;
567
+ let started = 0;
568
+ let aborted = signal.aborted;
569
+ let concluded = false;
570
+ let schedulerFailure;
571
+ const throwSchedulerFailure = () => {
572
+ if (schedulerFailure !== void 0) throw schedulerFailure.error;
573
+ };
574
+ const commitReady = async () => {
575
+ while (committed < group.length) {
576
+ const slot = slots[committed];
577
+ if (slot === void 0) break;
578
+ const call = group[committed];
579
+ const result = slot.needsPost ? await scheduler.finalize(slot.exec, slot.result) : scheduler.finish(slot.exec, slot.result);
580
+ appendToolResult(session, turn, step, call.block, result, callSeqs[committed]);
581
+ for (const context of result.additionalContexts ?? []) acceptContext(context);
582
+ concluded ||= result.concludesTurn === true;
583
+ committed++;
584
+ }
585
+ };
586
+ const inFlight = /* @__PURE__ */ new Map();
587
+ const startCall = async (index) => {
588
+ const call = group[index];
589
+ callSeqs[index] = appendToolCall(session, turn, step, call.block);
590
+ started++;
591
+ const prepared = await scheduler.prepare(call.exec);
592
+ throwSchedulerFailure();
593
+ switch (prepared.kind) {
594
+ case "dispatch": {
595
+ const promise = scheduler.dispatch(prepared.exec).then((outcome) => {
596
+ slots[index] = {
597
+ exec: prepared.exec,
598
+ result: outcome.result,
599
+ needsPost: outcome.kind === "post-result"
600
+ };
601
+ return index;
602
+ }, (error) => {
603
+ schedulerFailure ??= { error };
604
+ return index;
605
+ });
606
+ inFlight.set(index, promise);
607
+ break;
608
+ }
609
+ case "post-result":
610
+ slots[index] = {
611
+ exec: prepared.exec,
612
+ result: prepared.result,
613
+ needsPost: true
614
+ };
615
+ break;
616
+ case "final-result":
617
+ slots[index] = {
618
+ exec: prepared.exec,
619
+ result: prepared.result,
620
+ needsPost: false
621
+ };
622
+ break;
623
+ /* v8 ignore next -- closed-union exhaustiveness guard */
624
+ default: assertNever(prepared, "tool-call scheduler prepare result");
625
+ }
626
+ };
627
+ const fillPool = async () => {
628
+ while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
629
+ const nextCall = group[nextToStart];
630
+ if (nextToStart > 0 && mode === "parallel" && ctx.tools.executionMode(nextCall.exec).kind !== "parallel") break;
631
+ await startCall(nextToStart);
632
+ nextToStart++;
633
+ throwSchedulerFailure();
634
+ await commitReady();
635
+ throwSchedulerFailure();
636
+ if (signal.aborted) aborted = true;
637
+ }
638
+ };
639
+ try {
640
+ await fillPool();
641
+ while (inFlight.size > 0) {
642
+ const settledIndex = await Promise.race(inFlight.values());
643
+ inFlight.delete(settledIndex);
644
+ throwSchedulerFailure();
645
+ await commitReady();
646
+ throwSchedulerFailure();
647
+ if (signal.aborted) aborted = true;
648
+ await fillPool();
649
+ }
650
+ } catch (error) {
651
+ schedulerFailure ??= { error };
652
+ await Promise.allSettled(inFlight.values());
653
+ throw schedulerFailure.error;
654
+ }
655
+ if (aborted) {
656
+ for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block);
657
+ return {
658
+ consumed: group.length,
659
+ aborted: true,
660
+ concluded
661
+ };
662
+ }
663
+ /* v8 ignore next -- unreachable: a non-aborted group commits every started call */
664
+ if (committed !== started) throw new Error("tool-call scheduler: uncommitted settled calls");
665
+ return {
666
+ consumed: started,
667
+ aborted: false,
668
+ concluded
669
+ };
670
+ }
671
+ /** Append the durable call/result pair for a model call skipped after cancellation. */
672
+ function appendSkippedToolCall(session, turn, step, block) {
673
+ const callSeq = appendToolCall(session, turn, step, block);
674
+ appendToolResult(session, turn, step, block, {
675
+ content: [{
676
+ type: "text",
677
+ text: "Error: tool call aborted before dispatch"
678
+ }],
679
+ isError: true,
680
+ error: {
681
+ message: "tool call aborted before dispatch",
682
+ info: {
683
+ name: "AbortError",
684
+ code: TOOL_ABORTED_BEFORE_DISPATCH
685
+ }
686
+ }
687
+ }, callSeq);
688
+ }
689
+ /** Append a started call and return the event seq that its result must cite. */
690
+ function appendToolCall(session, turn, step, block) {
691
+ return session.append("tool/call", {
692
+ turn,
693
+ step,
694
+ callId: block.id,
695
+ name: block.name,
696
+ arguments: block.arguments
697
+ }).seq;
698
+ }
699
+ /** Append a model-ordered result linked to its call event. */
700
+ function appendToolResult(session, turn, step, block, result, callSeq) {
701
+ const message = createToolResultMessage({
702
+ callId: block.id,
703
+ content: result.content,
704
+ isError: result.isError
705
+ });
706
+ session.append("tool/result", {
707
+ turn,
708
+ step,
709
+ message,
710
+ ...result.error?.info ? { error: result.error.info } : {},
711
+ ...result.meta !== void 0 ? { meta: result.meta } : {}
712
+ }, {
713
+ surfaceOp: "append",
714
+ sourceEventSeqs: [callSeq]
715
+ });
716
+ }
717
+ //#endregion
718
+ //#region lib/types/agent.js
719
+ /**
720
+ * Default Agent driver over queued turns and step-boundary input. Every request
721
+ * is derived from the session log.
722
+ * @module dsh-agent-loop/agent
723
+ */
724
+ /** Remove adapter-derived values before plugins propose the next request config. */
725
+ function requestProposal(header) {
726
+ if (header.adapterDefaults === void 0) return header.config;
727
+ const proposal = { ...header.config };
728
+ if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort;
729
+ if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens;
730
+ return proposal;
731
+ }
732
+ /**
733
+ * Read the cause `cancel()` passed when aborting a loop-owned signal, copying
734
+ * only the fields `turn/end` records. The live reason stays the caller's
735
+ * object, and Node's fetch assigns a `stack` onto it that `Session.append`
736
+ * would either log or reject as data JSON cannot hold.
737
+ * @param signal - a turn or maintenance signal this loop owns.
738
+ * @returns the copied cause, or undefined while the signal is still live.
739
+ */
740
+ function abortedCancelCause(signal) {
741
+ if (!signal.aborted) return void 0;
742
+ const cause = signal.reason;
743
+ switch (cause.kind) {
744
+ case "user":
745
+ case "parent":
746
+ case "disposed": return { kind: cause.kind };
747
+ case "hook": return {
748
+ kind: "hook",
749
+ reason: cause.reason
750
+ };
751
+ /* v8 ignore next -- cancel accepts the closed AgentCancelCause union */
752
+ default: return assertNever(cause);
753
+ }
754
+ }
755
+ /** Drives one session through turn and step boundaries. */
756
+ var ReactLoopAgent = class {
757
+ loopCtx;
758
+ id;
759
+ options;
760
+ session;
761
+ inbox;
762
+ phase;
763
+ activityDone = Promise.resolve();
764
+ /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */
765
+ scope;
766
+ ctx;
767
+ /** Fused dispatcher, built once in the constructor so hot-path dispatches never allocate. */
768
+ dispatch;
769
+ /** Whether this loop instance has appended its initial/resume request anchor. */
770
+ requestHeaderLogged = false;
771
+ /** Surface generation at attachment or the preceding built request. */
772
+ requestSurfaceGeneration;
773
+ runtimeContext;
774
+ /** Process-local revision of assistant frames for this attached Session. */
775
+ assistantStreamRevision = 0;
776
+ assistantAttemptCounter = 0;
777
+ systemPrompt;
778
+ /** Identities fully frozen by this loop; weak references do not retain replaced history. */
779
+ frozenMessages = /* @__PURE__ */ new WeakSet();
780
+ constructor(loopCtx, id, options, session) {
781
+ this.loopCtx = loopCtx;
782
+ this.id = id;
783
+ this.options = options;
784
+ this.session = session;
785
+ this.requestSurfaceGeneration = session.surface.contentGeneration;
786
+ this.dispatch = agentEvents(loopCtx, this);
787
+ this.scope = createScope(loopCtx, this);
788
+ this.ctx = this.scope.ctx;
789
+ this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch);
790
+ /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */
791
+ const lastTurn = this.loopCtx.sessionProjections.stateOf(session, "turnBoundary")?.lastTurn ?? 0;
792
+ this.phase = {
793
+ kind: "idle",
794
+ lastTurn
795
+ };
796
+ this.runtimeContext = new RuntimeContextProjection(this.ctx, session);
797
+ this.systemPrompt = new SystemPromptProjection(session);
798
+ }
799
+ get status() {
800
+ return this.phase.kind === "idle" || this.phase.kind === "maintenance" ? "idle" : "running";
801
+ }
802
+ /** Commit a phase and publish its externally visible status transition. */
803
+ setPhase(next) {
804
+ const previousStatus = this.status;
805
+ this.phase = next;
806
+ const status = this.status;
807
+ if (status !== previousStatus) this.dispatch.emit("agent/status", { status });
808
+ }
809
+ send(message, target, wakeup) {
810
+ const wakingAfterAbort = wakeup && this.phase.kind !== "idle" && this.phase.abort.signal.aborted;
811
+ const resolvedTarget = wakingAfterAbort ? "next-turn" : target;
812
+ this.inbox.splice(resolvedTarget, Infinity, 0, [message]);
813
+ if (wakeup) this.wakeDriver(wakingAfterAbort);
814
+ }
815
+ followup(input) {
816
+ this.send(input, "next-turn", true);
817
+ }
818
+ steer(input) {
819
+ this.send(input, "next-step", true);
820
+ }
821
+ inject(input) {
822
+ this.send(input, "next-step", false);
823
+ }
824
+ cancel(cause, options = {}) {
825
+ if (!options.keepInbox) {
826
+ this.inbox.clear();
827
+ if (this.phase.kind !== "idle") this.phase.wakeRequested = false;
828
+ }
829
+ if (this.phase.kind !== "idle") this.phase.abort.abort(cause);
830
+ }
831
+ runMaintenance(job) {
832
+ if (this.phase.kind !== "idle") throw new Error(`agent "${this.id}" already has active work`);
833
+ const done = Promise.withResolvers();
834
+ const maintenance = {
835
+ kind: "maintenance",
836
+ abort: new AbortController(),
837
+ lastTurn: this.phase.lastTurn,
838
+ wakeRequested: false
839
+ };
840
+ this.setPhase(maintenance);
841
+ this.activityDone = done.promise;
842
+ return (async () => {
843
+ try {
844
+ return await job(maintenance.abort.signal);
845
+ } finally {
846
+ this.setPhase({
847
+ kind: "idle",
848
+ lastTurn: maintenance.lastTurn
849
+ });
850
+ if (abortedCancelCause(maintenance.abort.signal)?.kind !== "disposed" && maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver();
851
+ done.resolve();
852
+ }
853
+ })();
854
+ }
855
+ /**
856
+ * Start one driver, or latch its wake behind maintenance or an aborted
857
+ * activity. A wake sent while idle always opens its turn boundary, even
858
+ * when its message was cleared; only a latched replay is suppressed when
859
+ * the queue no longer holds the wake.
860
+ * @param wakeAfterAbort - the {@link send} classification, captured before
861
+ * the inbox insertion so a reentrant cancel cannot reclassify it.
862
+ */
863
+ wakeDriver(wakeAfterAbort = false) {
864
+ if (this.phase.kind !== "idle") {
865
+ if (abortedCancelCause(this.phase.abort.signal)?.kind !== "disposed" && (this.phase.kind === "maintenance" || wakeAfterAbort)) this.phase.wakeRequested = true;
866
+ return;
867
+ }
868
+ const driver = Promise.withResolvers();
869
+ this.activityDone = driver.promise;
870
+ this.setPhase({
871
+ kind: "running",
872
+ abort: new AbortController(),
873
+ turn: this.phase.lastTurn,
874
+ step: 0,
875
+ wakeRequested: false
876
+ });
877
+ this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject);
878
+ }
879
+ async whenIdle() {
880
+ let activity;
881
+ do
882
+ await (activity = this.activityDone);
883
+ while (activity !== this.activityDone);
884
+ }
885
+ /** Report one failure at its live boundary, then preserve it for driver containment. */
886
+ throwError(error) {
887
+ const turn = this.phase.kind === "running" ? this.phase.turn : this.phase.lastTurn;
888
+ const step = this.phase.kind === "running" ? this.phase.step : 0;
889
+ this.dispatch.emit("agent/error", {
890
+ turn,
891
+ step,
892
+ error
893
+ });
894
+ throw error;
895
+ }
896
+ async kick() {
897
+ try {
898
+ while (await this.turn());
899
+ } catch (_error) {} finally {
900
+ /* v8 ignore next -- kick owns a running phase until this driver boundary */
901
+ if (this.phase.kind === "running") {
902
+ const { turn, wakeRequested } = this.phase;
903
+ this.setPhase({
904
+ kind: "idle",
905
+ lastTurn: turn
906
+ });
907
+ if (wakeRequested && this.inbox.hasPending) this.wakeDriver();
908
+ }
909
+ }
910
+ }
911
+ async preStep(target, position) {
912
+ /* v8 ignore next -- private callers establish the running phase before proposing a step */
913
+ if (this.phase.kind !== "running") throw new Error(`agent "${this.id}": pre-step outside running phase`);
914
+ const signal = this.phase.abort.signal;
915
+ const claimed = this.inbox.claim(target, position.turn);
916
+ const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal));
917
+ signal.throwIfAborted();
918
+ const decision = await this.dispatch.waterfall("agent/pre-step", {
919
+ messages: claimed,
920
+ ...position,
921
+ signal
922
+ }, () => {
923
+ const sections = renderContextSections(assembly);
924
+ const context = this.runtimeContext.project(joinContextSections(sections), sections);
925
+ return Promise.resolve({
926
+ kind: "enter",
927
+ messages: context === void 0 ? claimed : [...claimed, context]
928
+ });
929
+ });
930
+ signal.throwIfAborted();
931
+ if (decision.kind === "reject") return decision;
932
+ return {
933
+ ...decision,
934
+ assembly
935
+ };
936
+ }
937
+ /** Whether the assembled tool schemas differ from the logged request header's. */
938
+ toolsChanged(tools) {
939
+ const baseline = this.session.requestHeader();
940
+ if (baseline === void 0) return false;
941
+ return !headerEquals(baseline, canonicalHeader({
942
+ ...baseline,
943
+ tools: [...tools]
944
+ }));
945
+ }
946
+ /** Open one turn before claiming its first proposed step. */
947
+ async turn() {
948
+ if (this.phase.kind !== "running") this.throwError(/* @__PURE__ */ new Error(`agent "${this.id}": turn without driver reservation`));
949
+ const phase = this.phase;
950
+ const { signal } = phase.abort;
951
+ signal.throwIfAborted();
952
+ const turn = phase.turn + 1;
953
+ try {
954
+ this.session.append("turn/start", { turn });
955
+ } catch (error) {
956
+ this.throwError(error);
957
+ }
958
+ phase.turn = turn;
959
+ let turnEnds = null;
960
+ let target = "next-turn";
961
+ try {
962
+ while (true) {
963
+ signal.throwIfAborted();
964
+ const step = phase.step + 1;
965
+ const decision = await this.preStep(target, {
966
+ turn,
967
+ step
968
+ });
969
+ if (decision.kind === "reject") {
970
+ turnEnds = { kind: "blocked" };
971
+ return false;
972
+ }
973
+ if (turnEnds && decision.messages.length === 0) break;
974
+ if (phase.step === 0 && decision.messages.length === 0) {
975
+ turnEnds = { kind: "completed" };
976
+ return false;
977
+ }
978
+ signal.throwIfAborted();
979
+ this.session.append("step/start", {
980
+ turn,
981
+ step
982
+ });
983
+ phase.step = step;
984
+ const toolRecovery = new ToolCallRecovery();
985
+ const stopRecovery = this.ctx.on("session/event", (session, event) => {
986
+ if (session === this.session) toolRecovery.observe(event);
987
+ });
988
+ try {
989
+ const stepEnd = await this.step(decision);
990
+ if (turnEnds === null || turnEnds.kind !== "max-tokens") turnEnds = stepEnd;
991
+ } catch (error) {
992
+ try {
993
+ for (const event of toolRecovery.results()) this.session.append("tool/result", event.data, {
994
+ surfaceOp: "append",
995
+ ...event.sourceEventSeqs === void 0 ? {} : { sourceEventSeqs: event.sourceEventSeqs }
996
+ });
997
+ } catch (recoveryError) {
998
+ throw new AggregateError([error, recoveryError], "Step failed and its pending tool results could not be recorded", { cause: error });
999
+ }
1000
+ throw error;
1001
+ } finally {
1002
+ stopRecovery();
1003
+ this.session.append("step/end", {
1004
+ turn,
1005
+ step
1006
+ });
1007
+ }
1008
+ signal.throwIfAborted();
1009
+ if (turnEnds && this.inbox.nextStep.length === 0) {
1010
+ await this.dispatch.serial("agent/turn-stopping", {
1011
+ turn,
1012
+ signal
1013
+ });
1014
+ signal.throwIfAborted();
1015
+ }
1016
+ if (turnEnds && this.inbox.nextStep.length === 0) break;
1017
+ target = "next-step";
1018
+ }
1019
+ } catch (error) {
1020
+ const cause = abortedCancelCause(signal);
1021
+ if (cause !== void 0) {
1022
+ turnEnds = {
1023
+ kind: "aborted",
1024
+ reason: cause
1025
+ };
1026
+ throw error;
1027
+ }
1028
+ turnEnds = {
1029
+ kind: "error",
1030
+ error: error instanceof LlmError ? error.failure : {
1031
+ message: errorChain(error),
1032
+ code: "UNKNOWN"
1033
+ }
1034
+ };
1035
+ this.throwError(error);
1036
+ } finally {
1037
+ try {
1038
+ this.session.append("turn/end", {
1039
+ turn,
1040
+ reason: turnEnds
1041
+ });
1042
+ } catch (error) {
1043
+ this.throwError(error);
1044
+ }
1045
+ }
1046
+ if (!this.inbox.hasPending) return false;
1047
+ phase.abort = new AbortController();
1048
+ phase.wakeRequested = false;
1049
+ phase.step = 0;
1050
+ return true;
1051
+ }
1052
+ async step(decision) {
1053
+ /* v8 ignore next -- private callers establish the running phase before executing a step */
1054
+ if (this.phase.kind !== "running") throw new Error(`agent "${this.id}": step outside running phase`);
1055
+ const { turn, step, abort: { signal } } = this.phase;
1056
+ signal.throwIfAborted();
1057
+ const { assembly } = decision;
1058
+ const renderedPrompt = renderPrompt(assembly);
1059
+ let firstAttempt = true;
1060
+ while (true) {
1061
+ const { config, preparedCall } = await this.prepareRequest(turn, step, signal);
1062
+ const sections = renderContextSections(this.loopCtx.systemPrompt.refreshContext(assembly, assembleContextFor(this, signal)));
1063
+ const context = this.runtimeContext.project(joinContextSections(sections), sections);
1064
+ signal.throwIfAborted();
1065
+ const startsRequestSeries = firstAttempt && decision.startsRequestSeries === true;
1066
+ const commits = this.systemPrompt.project(renderedPrompt, {
1067
+ inHistory: preparedCall?.systemPromptUpdate === "in-history",
1068
+ startsSeries: startsRequestSeries || this.requestSurfaceGeneration !== this.session.surface.contentGeneration || preparedCall?.toolUpdate === void 0 && this.toolsChanged(assembly.tools)
1069
+ });
1070
+ for (const { message, intent } of commits) this.session.append("system/message", {
1071
+ turn,
1072
+ step,
1073
+ message
1074
+ }, intent);
1075
+ let contextAdmitted = false;
1076
+ if (firstAttempt) for (const message of decision.messages) if (message.source.kind === "runtime-context") {
1077
+ if (context !== void 0 && !contextAdmitted) {
1078
+ this.session.append("user/message", context, { surfaceOp: "append" });
1079
+ contextAdmitted = true;
1080
+ }
1081
+ } else this.session.append("user/message", message, { surfaceOp: "append" });
1082
+ if (context !== void 0 && !contextAdmitted) this.session.append("user/message", context, { surfaceOp: "append" });
1083
+ firstAttempt = false;
1084
+ const request = this.buildRequest(config, preparedCall, assembly.tools, {
1085
+ turn,
1086
+ step
1087
+ }, startsRequestSeries, signal);
1088
+ const live = new AssistantStreamAttempt(this.session.id, ++this.assistantAttemptCounter, () => ++this.assistantStreamRevision, turn, step, (frame) => {
1089
+ this.dispatch.emit("agent/assistant-stream", { frame });
1090
+ });
1091
+ let started = false;
1092
+ try {
1093
+ const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request);
1094
+ signal.throwIfAborted();
1095
+ live.start();
1096
+ started = true;
1097
+ for await (const chunk of stream) {
1098
+ signal.throwIfAborted();
1099
+ live.push(chunk);
1100
+ }
1101
+ signal.throwIfAborted();
1102
+ } catch (error) {
1103
+ if (!started) throw error;
1104
+ try {
1105
+ if (signal.aborted) {
1106
+ const content = live.interruptedBlocks();
1107
+ if (content.length > 0) live.settle("assistant/message", () => this.session.append("assistant/message", {
1108
+ turn,
1109
+ step,
1110
+ message: createAssistantMessage({
1111
+ content,
1112
+ source: {
1113
+ provider: request.provider,
1114
+ model: request.model,
1115
+ ...live.replayState === void 0 ? {} : { replayState: live.replayState }
1116
+ }
1117
+ }),
1118
+ interrupted: true,
1119
+ ...live.usage === void 0 ? {} : { usage: live.usage },
1120
+ stream: live.stream
1121
+ }, { surfaceOp: "append" }).seq);
1122
+ else live.settle("assistant/attempt", () => this.session.append("assistant/attempt", {
1123
+ turn,
1124
+ step,
1125
+ stream: live.stream
1126
+ }).seq);
1127
+ } else live.settle("assistant/attempt", () => this.session.append("assistant/attempt", {
1128
+ turn,
1129
+ step,
1130
+ stream: live.stream
1131
+ }).seq);
1132
+ } catch (settlementError) {
1133
+ throw new AggregateError([error, settlementError], "Assistant stream failed and its durable settlement was rejected", { cause: error });
1134
+ }
1135
+ throw error;
1136
+ }
1137
+ try {
1138
+ const finish = live.finish;
1139
+ if (finish.kind === "error" || finish.kind === "aborted") {
1140
+ live.settle("assistant/attempt", () => this.session.append("assistant/attempt", {
1141
+ turn,
1142
+ step,
1143
+ stream: live.stream
1144
+ }).seq);
1145
+ const action = await this.dispatch.waterfall("agent/request-error", {
1146
+ turn,
1147
+ step,
1148
+ provider: request.provider,
1149
+ failure: finish.failure,
1150
+ retryPolicy: preparedCall?.retryPolicy,
1151
+ signal
1152
+ }, () => Promise.resolve(void 0));
1153
+ signal.throwIfAborted();
1154
+ if (action?.kind !== "retry") throw new LlmError(finish.failure.message, finish.failure.code, finish.failure);
1155
+ continue;
1156
+ }
1157
+ const message = createAssistantMessage({
1158
+ content: live.blocks(),
1159
+ source: {
1160
+ provider: request.provider,
1161
+ model: request.model,
1162
+ ...live.replayState !== void 0 ? { replayState: live.replayState } : {}
1163
+ }
1164
+ });
1165
+ live.settle("assistant/message", () => this.session.append("assistant/message", {
1166
+ turn,
1167
+ step,
1168
+ message,
1169
+ ...live.usage === void 0 ? {} : { usage: live.usage },
1170
+ stream: live.stream
1171
+ }, { surfaceOp: "append" }).seq);
1172
+ if (finish.kind === "max-tokens") return { kind: "max-tokens" };
1173
+ const toolCalls = message.content.filter((block) => block.type === "tool-call");
1174
+ if (toolCalls.length === 0) return { kind: "completed" };
1175
+ const { concluded } = await executeToolCalls(this.loopCtx, turn, step, toolCalls, signal, (context) => this.inbox.splice("next-step", this.inbox.nextStep.length, 0, [context]));
1176
+ return concluded ? { kind: "completed" } : null;
1177
+ } catch (error) {
1178
+ if (!live.ended) live.abandon();
1179
+ throw error;
1180
+ }
1181
+ }
1182
+ }
1183
+ /** Resolve request config and bind its adapter before admitting model-visible input. */
1184
+ async prepareRequest(turn, step, signal) {
1185
+ const { session } = this;
1186
+ const persistedHeader = session.requestHeader();
1187
+ const persistedConfig = persistedHeader?.config;
1188
+ const route = {
1189
+ provider: this.options.provider ?? "",
1190
+ model: this.options.model ?? ""
1191
+ };
1192
+ const persistedReasoningEffort = persistedConfig?.provider === route.provider && persistedConfig.model === route.model && persistedHeader?.adapterDefaults?.reasoningEffort !== true ? persistedConfig.reasoningEffort : void 0;
1193
+ const reasoningEffort = this.options.reasoningEffort ?? persistedReasoningEffort;
1194
+ const maxTokens = this.options.maxTokens;
1195
+ const seedConfig = deepFreeze(structuredClone(this.requestHeaderLogged ? requestProposal(persistedHeader) : {
1196
+ ...route,
1197
+ ...reasoningEffort === void 0 ? {} : { reasoningEffort },
1198
+ ...maxTokens === void 0 ? {} : { maxTokens }
1199
+ }));
1200
+ const proposedConfig = await this.dispatch.waterfall("agent/request", {
1201
+ turn,
1202
+ step,
1203
+ signal
1204
+ }, () => Promise.resolve(seedConfig));
1205
+ signal.throwIfAborted();
1206
+ 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`);
1207
+ let config;
1208
+ let preparedCall;
1209
+ try {
1210
+ preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal);
1211
+ config = preparedCall.config;
1212
+ } catch (error) {
1213
+ if (!(error instanceof LlmError) || error.code !== "NO_ADAPTER") throw error;
1214
+ config = proposedConfig;
1215
+ }
1216
+ signal.throwIfAborted();
1217
+ return {
1218
+ config,
1219
+ ...preparedCall === void 0 ? {} : { preparedCall }
1220
+ };
1221
+ }
1222
+ /** Log the resolved envelope and derive a frozen request from the admitted surface. */
1223
+ buildRequest(config, preparedCall, tools, position, startsRequestSeries, signal) {
1224
+ const { session } = this;
1225
+ const surfaceGeneration = session.surface.contentGeneration;
1226
+ const header = canonicalHeader({
1227
+ config,
1228
+ ...preparedCall === void 0 ? {} : { adapterDefaults: preparedCall.adapterDefaults },
1229
+ ...tools.length > 0 ? { tools } : {}
1230
+ });
1231
+ const baseline = this.session.requestHeader();
1232
+ const startsSeries = startsRequestSeries || this.requestSurfaceGeneration !== surfaceGeneration;
1233
+ let headerSeq;
1234
+ if (!this.requestHeaderLogged) {
1235
+ headerSeq = this.session.append("request/header", {
1236
+ header,
1237
+ reason: baseline === void 0 ? "initial" : "resume",
1238
+ ...startsSeries ? { startsSeries: true } : {}
1239
+ }).seq;
1240
+ this.requestHeaderLogged = true;
1241
+ } else if (baseline === void 0 || !headerEquals(baseline, header)) headerSeq = this.session.append("request/header", {
1242
+ header,
1243
+ reason: "change",
1244
+ ...startsSeries ? { startsSeries: true } : {}
1245
+ }).seq;
1246
+ else if (startsSeries) this.session.append("request/header", {
1247
+ header,
1248
+ reason: "series"
1249
+ });
1250
+ if (baseline !== void 0 && headerSeq !== void 0) {
1251
+ const previousNames = new Set(baseline.tools?.map((tool) => tool.name));
1252
+ const currentNames = new Set(tools.map((tool) => tool.name));
1253
+ const additions = tools.filter((tool) => !previousNames.has(tool.name)).map((tool) => ({
1254
+ type: "tool-addition",
1255
+ toolName: tool.name
1256
+ }));
1257
+ const removals = (baseline.tools ?? []).filter((tool) => !currentNames.has(tool.name)).map((tool) => ({
1258
+ type: "tool-removal",
1259
+ toolName: tool.name
1260
+ }));
1261
+ if (additions.length > 0 || removals.length > 0) session.append("developer/message", {
1262
+ ...position,
1263
+ message: createDeveloperMessage({
1264
+ source: { kind: "tool-registry" },
1265
+ content: [...additions, ...removals]
1266
+ }),
1267
+ ...additions.length > 0 ? { headerSeq } : {}
1268
+ }, { surfaceOp: "append" });
1269
+ }
1270
+ this.requestSurfaceGeneration = surfaceGeneration;
1271
+ const contextWindow = preparedCall?.context?.contextWindow;
1272
+ const systemPromptUpdate = preparedCall?.systemPromptUpdate;
1273
+ const requestContext = {
1274
+ provider: config.provider,
1275
+ model: config.model,
1276
+ ...contextWindow === void 0 ? {} : { contextWindow },
1277
+ ...systemPromptUpdate === void 0 ? {} : { systemPromptUpdate }
1278
+ };
1279
+ const previousContext = session.requestContext();
1280
+ if (previousContext?.provider !== requestContext.provider || previousContext.model !== requestContext.model || previousContext.contextWindow !== requestContext.contextWindow || previousContext.systemPromptUpdate !== requestContext.systemPromptUpdate) session.append("request/context", requestContext);
1281
+ signal.throwIfAborted();
1282
+ deepFreeze(header);
1283
+ const boundaryMessages = session.deriveMessages();
1284
+ for (const message of boundaryMessages) {
1285
+ if (this.frozenMessages.has(message)) continue;
1286
+ deepFreeze(message);
1287
+ this.frozenMessages.add(message);
1288
+ }
1289
+ Object.freeze(boundaryMessages);
1290
+ return markAgentLoopRequest(Object.freeze({
1291
+ ...header.config,
1292
+ messages: boundaryMessages,
1293
+ toolHistory: session.toolHistory(),
1294
+ ...header.tools !== void 0 ? { tools: header.tools } : {},
1295
+ sessionId: this.session.id,
1296
+ signal
1297
+ }));
1298
+ }
1299
+ };
1300
+ //#endregion
1301
+ //#region lib/types/constants.js
1302
+ /** Shared agent-loop scheduler defaults.
1303
+ * @module dsh-agent-loop/constants
1304
+ */
1305
+ /** Default maximum in-flight parallel-safe calls per agent step. */
1306
+ const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10;
1307
+ //#endregion
1308
+ //#region lib/types/index.js
1309
+ var __addDisposableResource = function(env, value, async) {
1310
+ if (value !== null && value !== void 0) {
1311
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
1312
+ var dispose, inner;
1313
+ if (async) {
1314
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
1315
+ dispose = value[Symbol.asyncDispose];
1316
+ }
1317
+ if (dispose === void 0) {
1318
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
1319
+ dispose = value[Symbol.dispose];
1320
+ if (async) inner = dispose;
1321
+ }
1322
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
1323
+ if (inner) dispose = function() {
1324
+ try {
1325
+ inner.call(this);
1326
+ } catch (e) {
1327
+ return Promise.reject(e);
1328
+ }
1329
+ };
1330
+ env.stack.push({
1331
+ value,
1332
+ dispose,
1333
+ async
1334
+ });
1335
+ } else if (async) env.stack.push({ async: true });
1336
+ return value;
1337
+ };
1338
+ var __disposeResources = (function(SuppressedError) {
1339
+ return function(env) {
1340
+ function fail(e) {
1341
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
1342
+ env.hasError = true;
1343
+ }
1344
+ var r, s = 0;
1345
+ function next() {
1346
+ while (r = env.stack.pop()) try {
1347
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
1348
+ if (r.dispose) {
1349
+ var result = r.dispose.call(r.value);
1350
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
1351
+ fail(e);
1352
+ return next();
1353
+ });
1354
+ } else s |= 1;
1355
+ } catch (e) {
1356
+ fail(e);
1357
+ }
1358
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
1359
+ if (env.hasError) throw env.error;
1360
+ }
1361
+ return next();
1362
+ };
1363
+ })(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
1364
+ var e = new Error(message);
1365
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
1366
+ });
1367
+ /** Fiber states that cannot own or serve a new lifecycle. */
1368
+ const INACTIVE_STATES = new Set([
1369
+ 5,
1370
+ 4,
1371
+ 3
1372
+ ]);
1373
+ /** Host projection of agent turn and step boundaries. */
1374
+ const turnBoundaryProjectionDefinition = {
1375
+ key: "turnBoundary",
1376
+ stateVersion: 2,
1377
+ stateSchema: z$1.object({
1378
+ openTurnStartSeq: z$1.number().int().nonnegative().transform(SessionSeq).nullable(),
1379
+ lastStepStartSeq: z$1.number().int().nonnegative().transform(SessionSeq).nullable(),
1380
+ lastStepBoundary: z$1.object({
1381
+ kind: z$1.union([z$1.literal("start"), z$1.literal("end")]),
1382
+ seq: z$1.number().int().nonnegative().transform(SessionSeq)
1383
+ }).nullable(),
1384
+ lastTurn: z$1.number().int().nonnegative()
1385
+ }),
1386
+ init: () => ({
1387
+ openTurnStartSeq: null,
1388
+ lastStepStartSeq: null,
1389
+ lastStepBoundary: null,
1390
+ lastTurn: 0
1391
+ }),
1392
+ apply: (state, event) => {
1393
+ switch (event.type) {
1394
+ case "turn/start": return {
1395
+ ...state,
1396
+ openTurnStartSeq: event.seq,
1397
+ lastTurn: event.data.turn
1398
+ };
1399
+ case "turn/end": return {
1400
+ ...state,
1401
+ openTurnStartSeq: null
1402
+ };
1403
+ case "step/start": return {
1404
+ ...state,
1405
+ lastStepStartSeq: event.seq,
1406
+ lastStepBoundary: {
1407
+ kind: "start",
1408
+ seq: event.seq
1409
+ }
1410
+ };
1411
+ case "step/end": return {
1412
+ ...state,
1413
+ lastStepBoundary: {
1414
+ kind: "end",
1415
+ seq: event.seq
1416
+ }
1417
+ };
1418
+ default: return state;
1419
+ }
1420
+ }
1421
+ };
1422
+ /** Re-throw one collected failure as itself, or several as one `AggregateError`. */
1423
+ function throwCollectedFailures(failures, message) {
1424
+ if (failures.length === 1) throw failures[0];
1425
+ if (failures.length > 1) throw new AggregateError(failures, message);
1426
+ }
1427
+ /** Factory-level ownership: live agent teardowns plus config startup work. */
1428
+ var FactoryOwnership = class {
1429
+ fiber;
1430
+ accepting = true;
1431
+ teardown = new AbortController();
1432
+ inactive = Promise.withResolvers();
1433
+ liveAgents = /* @__PURE__ */ new Set();
1434
+ startupTasks = /* @__PURE__ */ new Set();
1435
+ constructor(fiber) {
1436
+ this.fiber = fiber;
1437
+ }
1438
+ /** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */
1439
+ get signal() {
1440
+ return this.teardown.signal;
1441
+ }
1442
+ isActive() {
1443
+ return this.accepting && !INACTIVE_STATES.has(this.fiber.state);
1444
+ }
1445
+ /** Track one live agent's shared teardown until it has run. */
1446
+ track(dispose) {
1447
+ this.liveAgents.add(dispose);
1448
+ return () => {
1449
+ this.liveAgents.delete(dispose);
1450
+ };
1451
+ }
1452
+ /** Join config startup work that begins before an agent exists. */
1453
+ trackStartup(job) {
1454
+ this.startupTasks.add(job);
1455
+ const forget = () => {
1456
+ this.startupTasks.delete(job);
1457
+ };
1458
+ job.then(forget, forget);
1459
+ }
1460
+ /** Join one public create/resume continuation; factory dispose awaits its settlement. */
1461
+ trackWrapper(job) {
1462
+ this.trackStartup(job.then(() => void 0, () => void 0));
1463
+ }
1464
+ /** Resolve `task`, or stop waiting when factory teardown begins. */
1465
+ async waitWhileActive(job) {
1466
+ await Promise.race([job, this.inactive.promise]);
1467
+ }
1468
+ async dispose() {
1469
+ this.accepting = false;
1470
+ this.teardown.abort(/* @__PURE__ */ new Error("agent loop is not active"));
1471
+ this.inactive.resolve();
1472
+ const settlements = await Promise.allSettled([...[...this.liveAgents].map((dispose) => dispose()), ...this.startupTasks]);
1473
+ const failures = [];
1474
+ for (const result of settlements) if (result.status === "rejected") failures.push(result.reason);
1475
+ throwCollectedFailures(failures, "agent loop disposal failed");
1476
+ }
1477
+ };
1478
+ /** Await `operation`, or throw the signal's reason as soon as it aborts. */
1479
+ async function raceAbort(operation, signal, id) {
1480
+ const toAbortError = () => signal.reason instanceof Error ? signal.reason : new Error(`agent "${id}" creation aborted`, { cause: signal.reason });
1481
+ if (signal.aborted) throw toAbortError();
1482
+ const aborted = Promise.withResolvers();
1483
+ const listener = () => {
1484
+ aborted.reject(toAbortError());
1485
+ };
1486
+ signal.addEventListener("abort", listener, { once: true });
1487
+ try {
1488
+ return await Promise.race([Promise.resolve(operation), aborted.promise]);
1489
+ } finally {
1490
+ signal.removeEventListener("abort", listener);
1491
+ }
1492
+ }
1493
+ /** Start an abortable operation and release a value that arrives after cancellation. */
1494
+ async function raceAbortCall(operation, signal, id, releaseAbandoned) {
1495
+ if (signal.aborted) throw signal.reason instanceof Error ? signal.reason : new Error(`agent "${id}" creation aborted`, { cause: signal.reason });
1496
+ const pending = Promise.resolve().then(operation);
1497
+ try {
1498
+ return await raceAbort(pending, signal, id);
1499
+ } catch (error) {
1500
+ if (signal.aborted && releaseAbandoned !== void 0) pending.then(releaseAbandoned, () => void 0);
1501
+ throw error;
1502
+ }
1503
+ }
1504
+ /** Reject an output-token cap that cannot be represented exactly on the request wire. */
1505
+ function assertAgentOptions(options) {
1506
+ if (options.maxTokens !== void 0 && (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) throw new TypeError("agent maxTokens must be a positive safe integer");
1507
+ }
1508
+ /**
1509
+ * Context key a launcher sets before any Loader entry mounts
1510
+ * (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
1511
+ * configured agents' session identities without a config key, so an overlay
1512
+ * repointing the row's model route cannot drop them.
1513
+ */
1514
+ const CONFIGURED_AGENT_IDENTITIES_KEY = "configuredAgentIdentities";
1515
+ /**
1516
+ * Apply launcher-owned identities over the configured agents, replacing both
1517
+ * identity keys for every entry the launcher named so a config-supplied
1518
+ * identity can never survive alongside a launcher-supplied one.
1519
+ * @param agents - the configured agent entries.
1520
+ * @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
1521
+ * @returns the entries with launcher-owned identities applied.
1522
+ */
1523
+ function applyLauncherIdentities(agents, identities) {
1524
+ if (identities === void 0) return agents;
1525
+ return agents.map((agent) => {
1526
+ const identity = identities[agent.id];
1527
+ if (identity === void 0) return agent;
1528
+ const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent;
1529
+ return identity.resume ? {
1530
+ ...rest,
1531
+ resumeSessionId: identity.id
1532
+ } : {
1533
+ ...rest,
1534
+ sessionId: identity.id
1535
+ };
1536
+ });
1537
+ }
1538
+ /** Reject self-contained identity conflicts before any configured agent starts. */
1539
+ function validateConfiguredAgents(agents) {
1540
+ const exactIdentities = /* @__PURE__ */ new Map();
1541
+ for (const { id, sessionId, resumeSessionId } of agents) {
1542
+ const hasResumeId = resumeSessionId !== void 0 && resumeSessionId !== "";
1543
+ if (sessionId !== void 0 && hasResumeId) throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`);
1544
+ const exactIdentity = hasResumeId ? resumeSessionId : sessionId;
1545
+ if (exactIdentity === void 0) continue;
1546
+ const firstId = exactIdentities.get(exactIdentity);
1547
+ if (firstId !== void 0) throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`);
1548
+ exactIdentities.set(exactIdentity, id);
1549
+ }
1550
+ }
1551
+ /** Concrete agent factory and driver service. */
1552
+ var AgentLoop = class extends Service {
1553
+ static inject = [
1554
+ "agents",
1555
+ "sessions",
1556
+ "llm",
1557
+ "tools",
1558
+ "systemPrompt",
1559
+ "sessionProjections"
1560
+ ];
1561
+ /** Runtime schema for declarative agents. */
1562
+ static Config = z.object({
1563
+ maxParallelToolCalls: z.number().step(1).min(1).default(10).volatile(),
1564
+ agents: z.array(z.object({
1565
+ id: z.string().required(),
1566
+ sessionId: z.string().min(1),
1567
+ provider: z.string(),
1568
+ model: z.string(),
1569
+ reasoningEffort: z.string().min(1),
1570
+ maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
1571
+ cwd: z.string(),
1572
+ resumeSessionId: z.string()
1573
+ })).default([])
1574
+ });
1575
+ /** Validated configuration owned by the agent-loop service. */
1576
+ config;
1577
+ ownership;
1578
+ /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
1579
+ runtime;
1580
+ constructor(ctx, config) {
1581
+ super(ctx, "agentLoop");
1582
+ this.config = {
1583
+ agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
1584
+ maxParallelToolCalls: config.maxParallelToolCalls
1585
+ };
1586
+ validateConfiguredAgents(this.config.agents);
1587
+ const ownership = this.ownership = new FactoryOwnership(ctx.fiber);
1588
+ this.runtime = { ctx };
1589
+ ctx.effect(function* () {
1590
+ const unregisterTurnBoundary = ctx.sessionProjections.register(turnBoundaryProjectionDefinition);
1591
+ yield unregisterTurnBoundary;
1592
+ const unregisterInbox = ctx.sessionProjections.register(inboxProjectionDefinition);
1593
+ yield unregisterInbox;
1594
+ yield () => ownership.dispose().finally(() => {
1595
+ unregisterInbox();
1596
+ unregisterTurnBoundary();
1597
+ });
1598
+ }, "agentLoop.transactions()");
1599
+ ctx.effect(() => ctx.agents.setFactory(this), "agentLoop.setFactory()");
1600
+ ctx.systemPrompt.variable("provider", (context) => context.agent?.options.provider);
1601
+ ctx.systemPrompt.variable("model", (context) => context.agent?.options.model);
1602
+ for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
1603
+ const meta = cwd === void 0 ? {} : { cwd };
1604
+ if (resumeSessionId === void 0 || resumeSessionId === "") {
1605
+ const configuredId = sessionId ?? brandString(`${id}-session-${randomUUID()}`);
1606
+ const persistence = sessionId === void 0 ? void 0 : ctx.get("sessionPersistence");
1607
+ if (persistence === void 0) {
1608
+ const startup = this.create(configuredId, options, meta).then(() => void 0, (error) => {
1609
+ this.reportConfiguredStartupFailure(id, "restore", configuredId, error);
1610
+ });
1611
+ this.ownership.trackStartup(startup);
1612
+ } else {
1613
+ const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error) => {
1614
+ this.reportConfiguredStartupFailure(id, "restore", configuredId, error);
1615
+ });
1616
+ this.ownership.trackStartup(startup);
1617
+ }
1618
+ continue;
1619
+ }
1620
+ ctx.effect(() => {
1621
+ return ctx.inject(["sessionPersistence"], (childCtx) => {
1622
+ this.resumeWith(ctx, childCtx.sessionPersistence, {
1623
+ resumeSessionId,
1624
+ agentOptions: options
1625
+ }).catch((error) => {
1626
+ this.reportConfiguredStartupFailure(id, "resume", resumeSessionId, error);
1627
+ });
1628
+ }).dispose;
1629
+ }, `agentLoop.resume(${id})`);
1630
+ }
1631
+ }
1632
+ /** Report a contained declarative-start failure to identity-bound consumers. */
1633
+ reportConfiguredStartupFailure(configId, action, sessionId, error) {
1634
+ if (!this.ownership.isActive()) return;
1635
+ this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`);
1636
+ const args = ["agent-loop/config-start-failed", {
1637
+ sessionId,
1638
+ error
1639
+ }];
1640
+ for (const callback of this.ctx.events.dispatch("emit", args)) try {
1641
+ const returned = callback(...args);
1642
+ Promise.resolve(returned).catch((listenerError) => {
1643
+ this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`);
1644
+ });
1645
+ } catch (listenerError) {
1646
+ this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`);
1647
+ }
1648
+ }
1649
+ /** Restore a materialized exact config identity on remount, or create it on first use. */
1650
+ async restoreOrCreateConfigured(ownerCtx, persistence, sessionId, agentOptions, meta) {
1651
+ await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId);
1652
+ if (!this.ownership.isActive()) return;
1653
+ try {
1654
+ await this.resumeWith(ownerCtx, persistence, {
1655
+ resumeSessionId: sessionId,
1656
+ agentOptions
1657
+ });
1658
+ return;
1659
+ } catch (error) {
1660
+ if (!this.ownership.isActive()) return;
1661
+ if (!(error instanceof SessionPersistenceNotFoundError)) throw error;
1662
+ }
1663
+ await this.create(sessionId, agentOptions, meta);
1664
+ }
1665
+ /** Wait for a draining same-id lifecycle to finish registry teardown. */
1666
+ async waitForDrainingConfiguredIdentity(ownerCtx, sessionId) {
1667
+ if (ownerCtx.agents.get(sessionId) === void 0 && ownerCtx.sessions.get(sessionId) === void 0) return;
1668
+ const released = Promise.withResolvers();
1669
+ const checkReleased = () => {
1670
+ if (ownerCtx.agents.get(sessionId) === void 0 && ownerCtx.sessions.get(sessionId) === void 0) released.resolve();
1671
+ };
1672
+ const disposeAgentListener = ownerCtx.on("agent/disposed", () => {
1673
+ checkReleased();
1674
+ });
1675
+ const disposeSessionListener = ownerCtx.on("session/disposed", checkReleased);
1676
+ try {
1677
+ checkReleased();
1678
+ await this.ownership.waitWhileActive(released.promise);
1679
+ } finally {
1680
+ disposeAgentListener();
1681
+ disposeSessionListener();
1682
+ }
1683
+ }
1684
+ /**
1685
+ * Construct the driver, scope, and one memoized reverse teardown for a new
1686
+ * agent. The teardown is registered with the factory and the owner fiber
1687
+ * BEFORE publication, so a mid-setup unload rolls everything back; `signal`
1688
+ * fuses caller cancellation with lifecycle teardown for setup awaits.
1689
+ */
1690
+ prepare(ownerCtx, id, options, session, callerSignal, handle, parentAgent) {
1691
+ assertAgentOptions(options);
1692
+ ownerCtx.fiber.assertActive();
1693
+ /* v8 ignore next -- unreachable backstop, see above */
1694
+ if (!this.ownership.isActive()) throw new Error("agent loop is not active");
1695
+ if (callerSignal?.aborted) throw callerSignal.reason instanceof Error ? callerSignal.reason : new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason });
1696
+ const loopCtx = this.runtime.ctx;
1697
+ const abort = new AbortController();
1698
+ const onCallerAbort = () => {
1699
+ abort.abort(callerSignal?.reason instanceof Error ? callerSignal.reason : new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason }));
1700
+ };
1701
+ const onFactoryTeardown = () => {
1702
+ abort.abort(this.ownership.signal.reason);
1703
+ };
1704
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
1705
+ this.ownership.signal.addEventListener("abort", onFactoryTeardown, { once: true });
1706
+ let machine;
1707
+ let detachSession;
1708
+ let detachAgent;
1709
+ let disposing;
1710
+ let publication;
1711
+ const machineReady = Promise.withResolvers();
1712
+ const teardown = () => disposing ??= (async () => {
1713
+ abort.abort(/* @__PURE__ */ new Error(this.ownership.isActive() ? `agent "${id}" lifecycle disposed` : "agent loop is not active"));
1714
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1715
+ this.ownership.signal.removeEventListener("abort", onFactoryTeardown);
1716
+ const failures = [];
1717
+ try {
1718
+ if (publication !== void 0) await publication.promise;
1719
+ /* v8 ignore next -- Cordis effect teardown waits for synchronous setup before observing the machine slot. */
1720
+ if (machine === void 0) await machineReady.promise;
1721
+ /* v8 ignore next -- setup failure untracks this disposer before resolving without a machine. */
1722
+ if (machine !== void 0) {
1723
+ machine.cancel({ kind: "disposed" });
1724
+ await machine.whenIdle();
1725
+ }
1726
+ } catch (error) {
1727
+ failures.push(error);
1728
+ }
1729
+ try {
1730
+ await machine?.scope.dispose();
1731
+ } catch (error) {
1732
+ failures.push(error);
1733
+ }
1734
+ try {
1735
+ await handle?.close();
1736
+ } catch (error) {
1737
+ failures.push(error);
1738
+ }
1739
+ try {
1740
+ detachAgent?.();
1741
+ detachSession?.();
1742
+ } finally {
1743
+ untrack();
1744
+ }
1745
+ throwCollectedFailures(failures, `agent "${id}" disposal failed`);
1746
+ })();
1747
+ let unfollowScope;
1748
+ let unfollowOwner;
1749
+ const dispose = async () => {
1750
+ try {
1751
+ await teardown();
1752
+ } finally {
1753
+ await Promise.all([unfollowOwner(), unfollowScope?.()]);
1754
+ }
1755
+ };
1756
+ const untrack = this.ownership.track(dispose);
1757
+ try {
1758
+ unfollowOwner = ownerCtx.effect(function* () {
1759
+ const created = machine = new ReactLoopAgent(loopCtx, id, options, session);
1760
+ machineReady.resolve();
1761
+ if (!INACTIVE_STATES.has(loopCtx.fiber.state)) unfollowScope = loopCtx.effect(function* () {
1762
+ yield created.scope.rawDispose;
1763
+ yield () => teardown();
1764
+ }, `agentLoop.scope(${id})`);
1765
+ yield async () => {
1766
+ abort.abort(/* @__PURE__ */ new Error(`agent "${id}" setup aborted: owner disposed during setup`));
1767
+ try {
1768
+ await teardown();
1769
+ } finally {
1770
+ await unfollowScope?.();
1771
+ }
1772
+ };
1773
+ }, `agentLoop.lifecycle(${id})`);
1774
+ } catch (error) {
1775
+ machineReady.resolve();
1776
+ untrack();
1777
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1778
+ this.ownership.signal.removeEventListener("abort", onFactoryTeardown);
1779
+ throw error;
1780
+ }
1781
+ /* v8 ignore stop */
1782
+ const assertLive = () => {
1783
+ if (!abort.signal.aborted) return;
1784
+ /* v8 ignore next -- unreachable String() arm, see above */
1785
+ throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason));
1786
+ };
1787
+ try {
1788
+ /* v8 ignore next -- a synchronous effect exhausts the generator before returning */
1789
+ if (machine === void 0) throw new Error(`agent "${id}" lifecycle did not construct its driver`);
1790
+ const agent = machine;
1791
+ assertLive();
1792
+ return {
1793
+ agent,
1794
+ signal: abort.signal,
1795
+ publish: async (source) => {
1796
+ publication = Promise.withResolvers();
1797
+ try {
1798
+ assertLive();
1799
+ detachSession = agent.ctx.sessions.enter(session);
1800
+ detachAgent = loopCtx.agents.enter(agent, parentAgent);
1801
+ agent.ctx.sessions.announce(session);
1802
+ assertLive();
1803
+ await loopCtx.agents.announce(agent, source, abort.signal);
1804
+ assertLive();
1805
+ return {
1806
+ agent,
1807
+ dispose
1808
+ };
1809
+ } finally {
1810
+ publication.resolve();
1811
+ publication = void 0;
1812
+ }
1813
+ },
1814
+ dispose
1815
+ };
1816
+ } catch (error) {
1817
+ machineReady.resolve();
1818
+ dispose().catch(() => {});
1819
+ throw error;
1820
+ }
1821
+ }
1822
+ /**
1823
+ * Create an agent and session under one caller-supplied identity, owned by
1824
+ * the accessing fiber. Constructor-driven config calls mint a fresh combined
1825
+ * id before entering this boundary. When a persistence backend is mounted,
1826
+ * the session's durable identity and any seed are stored before publication.
1827
+ * @param id - shared agent/session identity.
1828
+ * @param options - concrete loop options.
1829
+ * @param meta - optional fresh-session workspace metadata.
1830
+ * @returns the published running agent.
1831
+ */
1832
+ async create(id, options = {}, meta = {}) {
1833
+ const env_1 = {
1834
+ stack: [],
1835
+ error: void 0,
1836
+ hasError: false
1837
+ };
1838
+ try {
1839
+ const preparation = __addDisposableResource(env_1, SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta })), false);
1840
+ const stored = await this.createStoredSession(preparation.session);
1841
+ let prepared;
1842
+ try {
1843
+ prepared = this.prepare(this.ctx, id, options, preparation.session, void 0, stored?.handle);
1844
+ } catch (error) {
1845
+ await stored?.handle.close().catch(() => {});
1846
+ throw error;
1847
+ }
1848
+ return (await this.initializeAgent(prepared, async () => {
1849
+ await this.appendUnstoredSuffix(stored, preparation.session);
1850
+ return await prepared.publish("startup");
1851
+ })).agent;
1852
+ } catch (e_1) {
1853
+ env_1.error = e_1;
1854
+ env_1.hasError = true;
1855
+ } finally {
1856
+ __disposeResources(env_1);
1857
+ }
1858
+ }
1859
+ /**
1860
+ * Take a fresh session's write ownership when persistence is mounted.
1861
+ * Nothing is appended here: the constructor seed (which never re-emits
1862
+ * through `session/event`) is stored by `appendUnstoredSuffix` at the
1863
+ * publication commit point, so a failed or cancelled validation or setup
1864
+ * closes an unmaterialized handle and leaves no stored residue — the same
1865
+ * id can be created again.
1866
+ * @param session - the unpublished session to store.
1867
+ * @param signal - optional cancellation forwarded to the backend create.
1868
+ * @returns the owned handle and stored cursor, or `undefined` without a backend.
1869
+ */
1870
+ async createStoredSession(session, signal) {
1871
+ const persistence = this.runtime.ctx.get("sessionPersistence");
1872
+ if (persistence === void 0) return void 0;
1873
+ return {
1874
+ handle: await persistence.create(session.header, {
1875
+ inheritedEventCount: session.inheritedEventCount,
1876
+ ...signal === void 0 ? {} : { signal }
1877
+ }),
1878
+ storedCount: 0
1879
+ };
1880
+ }
1881
+ /**
1882
+ * Durably store the session events appended since the last stored cursor.
1883
+ * Pre-publication appends (constructor seed markers, setup-window events
1884
+ * such as delegation policy records) never re-emit through `session/event`,
1885
+ * so publication must flush them through the handle before live events
1886
+ * start routing into it.
1887
+ * @param stored - the session's owned handle and stored cursor, if any.
1888
+ * @param session - the unpublished session whose suffix is stored.
1889
+ */
1890
+ async appendUnstoredSuffix(stored, session) {
1891
+ if (stored === void 0) return;
1892
+ const suffix = session.snapshotEvents(SessionLogOffset(stored.storedCount));
1893
+ if (suffix.length > 0) await stored.handle.append(suffix);
1894
+ stored.storedCount += suffix.length;
1895
+ }
1896
+ /**
1897
+ * Create an owned agent on a caller-supplied session id.
1898
+ * @param ownerCtx - caller context that structurally owns the lifecycle.
1899
+ * @param options - identities, optional live parent, session seed/metadata, loop options, setup, and cancellation.
1900
+ * @returns the published handle.
1901
+ */
1902
+ async createAgent(ownerCtx, options) {
1903
+ const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
1904
+ ...options.seed === void 0 ? {} : { seed: options.seed },
1905
+ ...options.meta === void 0 ? {} : { meta: options.meta },
1906
+ ...options.inheritedEventCount === void 0 ? {} : { inheritedEventCount: options.inheritedEventCount }
1907
+ }));
1908
+ const published = (async () => {
1909
+ let stored;
1910
+ try {
1911
+ stored = options.signal === void 0 ? await this.createStoredSession(preparation.session) : await raceAbortCall(() => this.createStoredSession(preparation.session, options.signal), options.signal, options.sessionId, (abandoned) => {
1912
+ abandoned?.handle.close().catch(() => {});
1913
+ });
1914
+ } catch (error) {
1915
+ preparation[Symbol.dispose]();
1916
+ throw error;
1917
+ }
1918
+ return this.setupAndPublish(ownerCtx, options.sessionId, preparation, options.agentOptions ?? {}, options.setup, options.signal, "startup", stored, options.parentAgent);
1919
+ })();
1920
+ this.ownership.trackWrapper(published);
1921
+ return published;
1922
+ }
1923
+ /** Prepare one Agent around an acquired Session, run setup, and publish it. */
1924
+ async setupAndPublish(ownerCtx, id, preparation, agentOptions, setup, signal, source, stored, parentAgent) {
1925
+ const env_2 = {
1926
+ stack: [],
1927
+ error: void 0,
1928
+ hasError: false
1929
+ };
1930
+ try {
1931
+ const session = __addDisposableResource(env_2, preparation, false).session;
1932
+ let prepared;
1933
+ try {
1934
+ prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, stored?.handle, parentAgent);
1935
+ } catch (error) {
1936
+ await stored?.handle.close().catch(() => {});
1937
+ throw error;
1938
+ }
1939
+ return await this.initializeAgent(prepared, async () => {
1940
+ (await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id))?.commit();
1941
+ await this.appendUnstoredSuffix(stored, session);
1942
+ return await prepared.publish(source);
1943
+ });
1944
+ } catch (e_2) {
1945
+ env_2.error = e_2;
1946
+ env_2.hasError = true;
1947
+ } finally {
1948
+ __disposeResources(env_2);
1949
+ }
1950
+ }
1951
+ async initializeAgent(prepared, initialize) {
1952
+ try {
1953
+ return await prepared.agent.runMaintenance(async () => {
1954
+ try {
1955
+ return await initialize();
1956
+ } catch (error) {
1957
+ prepared.agent.cancel({ kind: "disposed" }, { keepInbox: true });
1958
+ throw error;
1959
+ }
1960
+ });
1961
+ } catch (error) {
1962
+ await prepared.dispose().catch(() => {});
1963
+ throw error;
1964
+ }
1965
+ }
1966
+ /**
1967
+ * Resume an owned agent from the configured persistence service.
1968
+ * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
1969
+ * @param options - persisted identity, optional live parent, loop options, setup, and cancellation.
1970
+ * @returns the published handle.
1971
+ */
1972
+ async resume(ownerCtx, options) {
1973
+ const persistence = this.runtime.ctx.get("sessionPersistence");
1974
+ if (persistence === void 0) throw new Error("cannot resume: session persistence is not configured (load a dsh-session-persistence backend)");
1975
+ return this.resumeWith(ownerCtx, persistence, options);
1976
+ }
1977
+ /** Resume through an explicit persistence handle used by the deferred config path. */
1978
+ resumeWith(ownerCtx, persistence, options) {
1979
+ const id = options.resumeSessionId;
1980
+ const published = (async () => {
1981
+ const ownerAbort = new AbortController();
1982
+ const unfollowOwner = ownerCtx.effect(() => () => {
1983
+ ownerAbort.abort(/* @__PURE__ */ new Error(`agent "${id}" setup aborted: owner disposed during setup`));
1984
+ }, `agentLoop.resume-load(${id})`);
1985
+ const fused = AbortSignal.any([
1986
+ ...options.signal === void 0 ? [] : [options.signal],
1987
+ ownerAbort.signal,
1988
+ this.ownership.signal
1989
+ ]);
1990
+ let handle;
1991
+ let stored;
1992
+ let preparation;
1993
+ try {
1994
+ try {
1995
+ handle = await raceAbortCall(() => persistence.open(id, "write", { signal: fused }), fused, id, (abandoned) => {
1996
+ abandoned.close();
1997
+ });
1998
+ const coldRead = await handle.read(0, void 0, { signal: fused });
1999
+ fused.throwIfAborted();
2000
+ const persisted = coldRead.events;
2001
+ const closers = interruptedTurnClosers(persisted);
2002
+ if (closers.length > 0) await handle.append(closers);
2003
+ preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, {
2004
+ seed: [...persisted, ...closers],
2005
+ meta: structuredClone(handle.header),
2006
+ inheritedEventCount: handle.inheritedEventCount,
2007
+ eventState: coldRead.eventState
2008
+ }));
2009
+ stored = {
2010
+ handle,
2011
+ storedCount: persisted.length + closers.length
2012
+ };
2013
+ await this.appendUnstoredSuffix(stored, preparation.session);
2014
+ } finally {
2015
+ await unfollowOwner();
2016
+ }
2017
+ ownerCtx.fiber.assertActive();
2018
+ if (!this.ownership.isActive()) throw new Error("agent loop is not active");
2019
+ const owned = stored;
2020
+ handle = void 0;
2021
+ return await this.setupAndPublish(ownerCtx, id, preparation, options.agentOptions ?? {}, options.setup, options.signal, "resume", owned, options.parentAgent);
2022
+ } finally {
2023
+ preparation?.[Symbol.dispose]();
2024
+ await handle?.close().catch(() => {});
2025
+ }
2026
+ })();
2027
+ this.ownership.trackWrapper(published);
2028
+ return published;
2029
+ }
2030
+ };
2031
+ //#endregion
2032
+ export { AgentLoop, AgentLoop as default, CONFIGURED_AGENT_IDENTITIES_KEY, DEFAULT_MAX_PARALLEL_TOOL_CALLS, turnBoundaryProjectionDefinition };