@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/LICENSE +21 -0
- package/README.i18n.yaml +112 -0
- package/README.md +229 -2
- package/README.zh.md +230 -0
- package/lib/index.js +2032 -0
- package/lib/types/agent.d.ts +70 -0
- package/lib/types/assistant-stream.d.ts +53 -0
- package/lib/types/constants.d.ts +6 -0
- package/lib/types/inbox.d.ts +99 -0
- package/lib/types/index.d.ts +179 -0
- package/lib/types/runtime-context.d.ts +75 -0
- package/lib/types/tool-calls.d.ts +38 -0
- package/locale/en.json +5 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -3
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 };
|