@slopus/happy-agent-base 0.0.0 → 0.0.1

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -16
  3. package/dist/Agent.d.ts +31 -0
  4. package/dist/Agent.d.ts.map +1 -0
  5. package/dist/Agent.js +119 -0
  6. package/dist/Agent.js.map +1 -0
  7. package/dist/AgentBase.d.ts +93 -0
  8. package/dist/AgentBase.d.ts.map +1 -0
  9. package/dist/AgentBase.js +909 -0
  10. package/dist/AgentBase.js.map +1 -0
  11. package/dist/AgentBaseContext.d.ts +22 -0
  12. package/dist/AgentBaseContext.d.ts.map +1 -0
  13. package/dist/AgentBaseContext.js +33 -0
  14. package/dist/AgentBaseContext.js.map +1 -0
  15. package/dist/AgentBaseHooks.d.ts +68 -0
  16. package/dist/AgentBaseHooks.d.ts.map +1 -0
  17. package/dist/AgentBaseHooks.js +2 -0
  18. package/dist/AgentBaseHooks.js.map +1 -0
  19. package/dist/AgentBasePersistence.d.ts +60 -0
  20. package/dist/AgentBasePersistence.d.ts.map +1 -0
  21. package/dist/AgentBasePersistence.js +2 -0
  22. package/dist/AgentBasePersistence.js.map +1 -0
  23. package/dist/AgentBaseState.d.ts +11 -0
  24. package/dist/AgentBaseState.d.ts.map +1 -0
  25. package/dist/AgentBaseState.js +2 -0
  26. package/dist/AgentBaseState.js.map +1 -0
  27. package/dist/AgentFeature.d.ts +29 -0
  28. package/dist/AgentFeature.d.ts.map +1 -0
  29. package/dist/AgentFeature.js +2 -0
  30. package/dist/AgentFeature.js.map +1 -0
  31. package/dist/AgentFeatureAction.d.ts +16 -0
  32. package/dist/AgentFeatureAction.d.ts.map +1 -0
  33. package/dist/AgentFeatureAction.js +2 -0
  34. package/dist/AgentFeatureAction.js.map +1 -0
  35. package/dist/AgentProviders.d.ts +17 -0
  36. package/dist/AgentProviders.d.ts.map +1 -0
  37. package/dist/AgentProviders.js +29 -0
  38. package/dist/AgentProviders.js.map +1 -0
  39. package/dist/AgentTool.d.ts +47 -0
  40. package/dist/AgentTool.d.ts.map +1 -0
  41. package/dist/AgentTool.js +5 -0
  42. package/dist/AgentTool.js.map +1 -0
  43. package/dist/index.d.ts +10 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +10 -1
  46. package/dist/index.js.map +1 -1
  47. package/package.json +42 -37
@@ -0,0 +1,909 @@
1
+ import { areProviderModelsCompatible } from "@slopus/happy-providers";
2
+ import { Value } from "@sinclair/typebox/value";
3
+ import { asyncLock, withLifetime } from "@steve.kite/stdlib";
4
+ import { withAgentBaseContext } from "./AgentBaseContext.js";
5
+ import { AgentProviders } from "./AgentProviders.js";
6
+ /** Race winner when an abort interrupts a wait on the stream or a running tool. */
7
+ const ABORTED = Symbol("aborted");
8
+ /**
9
+ * A single agent session over one provider. Messages arrive through two FIFO queues: steering
10
+ * messages inject as soon as the current assistant response and its tool batch finish, while
11
+ * sent messages wait until the agent would otherwise stop — no tool calls or steering remain.
12
+ * Each queue drains per its configured mode, and the conversation is durable through
13
+ * append-only persistence loaded on the first inference attempt.
14
+ */
15
+ export class AgentBase {
16
+ id;
17
+ /**
18
+ * The agent's own copy of the initial state, mutable directly; every inference reads the
19
+ * current values.
20
+ */
21
+ state;
22
+ #baseCtx;
23
+ #ctx;
24
+ #providers;
25
+ #providerId;
26
+ #persistence;
27
+ #model;
28
+ #effort;
29
+ #serviceTier;
30
+ #hooks;
31
+ /**
32
+ * Serializes every persistence operation together with its in-memory effect, so storage
33
+ * order always matches history order and a load never overlaps an append.
34
+ */
35
+ #persistenceLock = asyncLock({ reentry: "block" });
36
+ #steeringMode;
37
+ #sendMode;
38
+ #session;
39
+ #messages = [];
40
+ #steering = [];
41
+ #sends = [];
42
+ #pendingTools = [];
43
+ #pendingSequence = 0;
44
+ #loaded;
45
+ #recoveryChecked = false;
46
+ #compaction;
47
+ #abortController;
48
+ #turnRequested = false;
49
+ #runPromise;
50
+ #closed = false;
51
+ constructor(ctx, options) {
52
+ this.id = options.id;
53
+ this.#baseCtx = ctx;
54
+ this.#providers = options.providers;
55
+ this.#providerId = options.provider;
56
+ this.#persistence = options.persistence;
57
+ this.#hooks = options.hooks ?? {};
58
+ this.state = {
59
+ instructions: options.initialState?.instructions ?? "",
60
+ tools: [...(options.initialState?.tools ?? [])],
61
+ };
62
+ this.#model = options.model;
63
+ this.#effort = options.effort;
64
+ this.#serviceTier = options.serviceTier;
65
+ // Everything the agent does — hooks and tool executions included — runs on a context
66
+ // carrying its provider and the currently effective model, effort, and service tier.
67
+ this.#ctx = this.#deriveCtx();
68
+ this.#steeringMode = options.steeringMode ?? "one-at-a-time";
69
+ this.#sendMode = options.sendMode ?? "one-at-a-time";
70
+ }
71
+ #deriveCtx() {
72
+ return withAgentBaseContext(this.#baseCtx, {
73
+ provider: this.#providerId,
74
+ model: this.#model,
75
+ effort: this.#effort,
76
+ serviceTier: this.#serviceTier,
77
+ });
78
+ }
79
+ /**
80
+ * Queue a user message that injects as soon as the current assistant response and its tool
81
+ * batch finish; steering always takes precedence over sent messages. The returned promise
82
+ * resolves once the durable write lands; it waits neither for the history load nor for the
83
+ * turn, and a failed write keeps the message out of the conversation entirely.
84
+ */
85
+ async steer(ctx, message, options) {
86
+ await this.#enqueue(ctx, "steering.", this.#steering, message, options ?? {});
87
+ }
88
+ /**
89
+ * Queue a user message that waits until the agent would otherwise stop — no tool calls or
90
+ * steering remain — before injecting. The returned promise resolves once the durable write
91
+ * lands; it waits neither for the history load nor for the turn, and a failed write keeps
92
+ * the message out of the conversation entirely.
93
+ */
94
+ async send(ctx, message, options) {
95
+ await this.#enqueue(ctx, "send.", this.#sends, message, options ?? {});
96
+ }
97
+ async #enqueue(ctx, prefix, queue, message, options) {
98
+ if (this.#closed)
99
+ throw new Error("The agent has been closed.");
100
+ await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
101
+ const key = this.#queueKey(prefix);
102
+ await this.#persistence.writeValue(lockCtx, key, { message, options });
103
+ queue.push({ key, message, options });
104
+ this.#turnRequested = true;
105
+ this.#startRun();
106
+ });
107
+ }
108
+ /**
109
+ * Start the loop without a new message: load the durable state and, if a turn was cut off —
110
+ * queued messages, a dispatched tool batch without results, or an unanswered user or tool
111
+ * message — continue it to completion. On an idle history this loads and does nothing more.
112
+ */
113
+ start() {
114
+ if (this.#closed)
115
+ throw new Error("The agent has been closed.");
116
+ this.#startRun();
117
+ }
118
+ async waitForIdle() {
119
+ while (this.#runPromise !== undefined) {
120
+ await this.#runPromise;
121
+ }
122
+ }
123
+ /**
124
+ * Compact the conversation. The compaction waits for the active turn to end — or runs right
125
+ * away when idle — and replaces the compacted history with the provider's replacement
126
+ * context while keeping every message that joined the history after the snapshot. Calls made
127
+ * while a compaction is pending or running await that same compaction; the shared promise
128
+ * resolves when it completes and rejects when the provider reports failure.
129
+ */
130
+ async compact(ctx) {
131
+ if (this.#closed)
132
+ throw new Error("The agent has been closed.");
133
+ return this.#ensureCompaction();
134
+ }
135
+ #ensureCompaction() {
136
+ if (this.#compaction === undefined) {
137
+ let resolve;
138
+ let reject;
139
+ const promise = new Promise((res, rej) => {
140
+ resolve = res;
141
+ reject = rej;
142
+ });
143
+ this.#compaction = { promise, resolve, reject };
144
+ this.#turnRequested = true;
145
+ this.#startRun();
146
+ }
147
+ return this.#compaction.promise;
148
+ }
149
+ /**
150
+ * The system prompt for the next request: the hook's answer when one is provided, the
151
+ * mutable state otherwise. A throwing hook falls back to the state; it never fails the run.
152
+ */
153
+ #instructions() {
154
+ try {
155
+ return this.#hooks.instructions?.(this.#ctx) ?? this.state.instructions;
156
+ }
157
+ catch {
158
+ return this.state.instructions;
159
+ }
160
+ }
161
+ /**
162
+ * The tools for the next request or execution: the hook's answer when one is provided, the
163
+ * mutable state otherwise. A throwing hook falls back to the state; it never fails the run.
164
+ */
165
+ #tools() {
166
+ try {
167
+ return this.#hooks.tools?.(this.#ctx) ?? this.state.tools;
168
+ }
169
+ catch {
170
+ return this.state.tools;
171
+ }
172
+ }
173
+ /**
174
+ * Cancel the active turn: stop consuming the inference stream, settle still-running tool
175
+ * calls as aborted error results, and drop the queued turn request. Blocks that already
176
+ * finished stay in the history; an unfinished block is dropped. Messages still waiting in
177
+ * the steering and send queues stay durable and join the next requested turn. Resolves
178
+ * once the loop has stopped; a no-op when the agent is idle.
179
+ */
180
+ async abort() {
181
+ const run = this.#runPromise;
182
+ if (run === undefined)
183
+ return;
184
+ this.#turnRequested = false;
185
+ this.#abortController?.abort();
186
+ await run;
187
+ }
188
+ async close() {
189
+ if (this.#closed)
190
+ return;
191
+ this.#closed = true;
192
+ await this.#runPromise;
193
+ await this.#session?.destroy();
194
+ this.#session = undefined;
195
+ }
196
+ #startRun() {
197
+ if (this.#runPromise !== undefined)
198
+ return;
199
+ this.#runPromise = this.#runLoop().finally(() => {
200
+ this.#runPromise = undefined;
201
+ });
202
+ }
203
+ async #runLoop() {
204
+ // The outer loop reopens when an `afterAgentLoop` action requests more work, so the
205
+ // loop hooks always bracket a settled-to-settled span.
206
+ do {
207
+ this.#invokeHook(this.#hooks.beforeAgentLoop);
208
+ do {
209
+ this.#turnRequested = false;
210
+ this.#invokeHook(this.#hooks.beforeTurn);
211
+ await this.#runInference();
212
+ await this.#applyActions(this.#hooks.afterTurn);
213
+ } while (this.#turnRequested && !this.#closed);
214
+ await this.#applyActions(this.#hooks.afterAgentLoop);
215
+ } while (this.#turnRequested && !this.#closed);
216
+ }
217
+ #invokeHook(hook) {
218
+ try {
219
+ hook?.(this.#ctx);
220
+ }
221
+ catch {
222
+ // Hooks observe the run; they never fail it.
223
+ }
224
+ }
225
+ /**
226
+ * Ask a lifecycle hook what to do next and carry its actions out: queue steering or sent
227
+ * messages through the ordinary durable path, or trigger a compaction. Every returned
228
+ * action is applied before the loop continues, so they all take effect at the same point.
229
+ * Neither a throwing hook nor a failing action ever fails the run.
230
+ */
231
+ async #applyActions(hook) {
232
+ if (hook === undefined)
233
+ return;
234
+ let actions;
235
+ try {
236
+ actions = hook(this.#ctx);
237
+ }
238
+ catch {
239
+ return;
240
+ }
241
+ for (const action of actions ?? []) {
242
+ try {
243
+ if (action.type === "compact") {
244
+ this.#ensureCompaction().catch(() => undefined);
245
+ continue;
246
+ }
247
+ const queue = action.type === "steer" ? this.#steering : this.#sends;
248
+ const prefix = action.type === "steer" ? "steering." : "send.";
249
+ await this.#enqueue(this.#ctx, prefix, queue, action.message, {});
250
+ }
251
+ catch {
252
+ // A hook-driven action must not fail the run.
253
+ }
254
+ }
255
+ }
256
+ async #runInference() {
257
+ // One abort scope per pass; a single shared promise keeps races from piling up
258
+ // listeners on the signal.
259
+ const abort = new AbortController();
260
+ this.#abortController = abort;
261
+ const abortPromise = new Promise((resolve) => {
262
+ abort.signal.addEventListener("abort", () => resolve(ABORTED), { once: true });
263
+ });
264
+ try {
265
+ // A failed load is not sticky: the cache resets so the next turn retries it.
266
+ this.#loaded ??= this.#loadHistory().catch((error) => {
267
+ this.#loaded = undefined;
268
+ throw error;
269
+ });
270
+ await this.#loaded;
271
+ // Resume a tool batch that was dispatched but cut off before its results landed, so
272
+ // the interrupted results reach the main store before any queued message.
273
+ const resumed = this.#pendingTools;
274
+ this.#pendingTools = [];
275
+ if (resumed.length > 0) {
276
+ await this.#runToolBatch(resumed, true, abort.signal, abortPromise);
277
+ }
278
+ // A response is owed without any injection when tool results from a resumed batch
279
+ // end the context, or — checked once, against the freshly loaded durable state —
280
+ // when a cut-off run left its trailing user or tool message unanswered. Afterwards
281
+ // a trailing user message can be legitimate: a response may have zero blocks.
282
+ let responseOwed = resumed.length > 0;
283
+ if (!this.#recoveryChecked) {
284
+ this.#recoveryChecked = true;
285
+ const last = this.#messages[this.#messages.length - 1];
286
+ responseOwed ||= last?.role === "user" || last?.role === "tool";
287
+ }
288
+ // Each cycle first drains the queues, then runs one inference. Steering injects at
289
+ // every stop between responses and always outranks sends; sent messages inject
290
+ // only when the agent would otherwise stop — no tool results or steering remain.
291
+ // Queue consumption happens only here, between inferences, so an injected message
292
+ // can never interleave with an active response's block records.
293
+ // The message of an error response that has not been recovered from yet. A later
294
+ // successful response clears it; a turn that ends while it is set has failed and
295
+ // surfaces it to the context as a system message.
296
+ let pendingError;
297
+ while (true) {
298
+ // An abort during the tool batch ends the turn here, before the next inference.
299
+ if (abort.signal.aborted) {
300
+ this.#emit({ type: "done", state: "cancelled" });
301
+ break;
302
+ }
303
+ let injected = await this.#consumeQueue(this.#steering, this.#steeringMode);
304
+ if (!injected && !responseOwed) {
305
+ injected = await this.#consumeQueue(this.#sends, this.#sendMode);
306
+ }
307
+ // Nothing to answer — a start() on an idle history, or the queues ran dry.
308
+ if (!injected && !responseOwed)
309
+ break;
310
+ const session = await this.#ensureSession();
311
+ this.#invokeHook(this.#hooks.beforeInference);
312
+ const stream = session.run(this.#ctx, {
313
+ context: {
314
+ instructions: this.#instructions(),
315
+ messages: [...this.#messages],
316
+ },
317
+ ...(this.#model === undefined ? {} : { model: this.#model }),
318
+ ...(this.#effort === undefined ? {} : { effort: this.#effort }),
319
+ ...(this.#serviceTier === undefined
320
+ ? {}
321
+ : { serviceTier: this.#serviceTier }),
322
+ });
323
+ const { content, state, errorMessage } = await this.#collect(stream, abortPromise);
324
+ this.#invokeHook(this.#hooks.afterInference);
325
+ if (content.length > 0) {
326
+ this.#messages.push({ role: "assistant", content });
327
+ }
328
+ responseOwed = false;
329
+ pendingError = state === "error" ? errorMessage : undefined;
330
+ if (state === "tool_call") {
331
+ const calls = content.filter((block) => block.type === "tool_call" && block.server !== true);
332
+ if (calls.length === 0)
333
+ continue;
334
+ await this.#runToolBatch(calls.map((call, index) => ({
335
+ key: this.#toolKey(index, call.callId),
336
+ call,
337
+ })), false, abort.signal, abortPromise);
338
+ responseOwed = true;
339
+ continue;
340
+ }
341
+ // A natural stop keeps draining, and so does a provider-reported error: the
342
+ // failed response never answers the queued messages, so they still get their
343
+ // fresh inference — each drain consumes from a finite queue, so a persistently
344
+ // failing provider cannot loop. A cancellation or a stream that ended without
345
+ // a done event ends the turn with the queues intact.
346
+ if (state !== "normal" && state !== "length" && state !== "error")
347
+ break;
348
+ }
349
+ if (pendingError !== undefined) {
350
+ await this.#appendFailure(pendingError);
351
+ }
352
+ }
353
+ catch (error) {
354
+ this.#emit({
355
+ type: "done",
356
+ state: "error",
357
+ kind: "internal_error",
358
+ message: error instanceof Error ? error.message : String(error),
359
+ });
360
+ await this.#appendFailure(error instanceof Error ? error.message : String(error));
361
+ }
362
+ // The turn is over; a requested compaction runs now, before the next pass can start.
363
+ await this.#runCompaction();
364
+ }
365
+ /**
366
+ * Run the pending compaction, if any. The snapshot is taken here, with the turn over and
367
+ * this pass being the only history writer, so nothing joins the history mid-compaction; the
368
+ * suffix copy still keeps any such message, defensively. The replacement is appended as a
369
+ * compaction record — the load-time reset point — and settles the shared promise for every
370
+ * caller awaiting it. A provider failure rejects them and leaves the history untouched.
371
+ */
372
+ async #runCompaction() {
373
+ const pending = this.#compaction;
374
+ if (pending === undefined)
375
+ return;
376
+ try {
377
+ const session = await this.#ensureSession();
378
+ const snapshot = [...this.#messages];
379
+ const result = await session.compact(this.#ctx, {
380
+ context: { instructions: this.#instructions(), messages: snapshot },
381
+ ...(this.#model === undefined ? {} : { model: this.#model }),
382
+ });
383
+ if (result.status === "failed") {
384
+ throw new Error(result.message);
385
+ }
386
+ if (result.status === "completed") {
387
+ await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
388
+ const suffix = this.#messages.slice(snapshot.length);
389
+ const replaced = [...result.context.messages, ...suffix];
390
+ // Physically delete the superseded records and write the replacement —
391
+ // which keeps the messages that stay — in one atomic step.
392
+ await this.#persistence.transaction(lockCtx, async (txCtx) => {
393
+ await this.#persistence.clearRecords(txCtx);
394
+ await this.#persistence.append(txCtx, {
395
+ type: "compaction",
396
+ messages: replaced,
397
+ });
398
+ });
399
+ this.#messages = replaced;
400
+ });
401
+ }
402
+ this.#compaction = undefined;
403
+ pending.resolve();
404
+ }
405
+ catch (error) {
406
+ this.#compaction = undefined;
407
+ pending.reject(error);
408
+ }
409
+ }
410
+ /**
411
+ * Surface a failed turn to the conversation as a system message, so the next inference sees
412
+ * what went wrong. Only unrecovered failures reach here — a later successful response in the
413
+ * same turn clears its error without a trace. Skipped when the history never loaded, since
414
+ * there is no context to append to; its own failure is swallowed, so surfacing a failure can
415
+ * never cause another.
416
+ */
417
+ async #appendFailure(message) {
418
+ if (this.#loaded === undefined)
419
+ return;
420
+ const failure = {
421
+ role: "system",
422
+ content: [{ type: "text", text: `The last turn failed: ${message}` }],
423
+ };
424
+ try {
425
+ await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
426
+ await this.#persistence.append(lockCtx, { type: "system", message: failure });
427
+ this.#messages.push(failure);
428
+ });
429
+ }
430
+ catch {
431
+ // The turn already failed; a failing write must not escalate it.
432
+ }
433
+ }
434
+ /**
435
+ * Move the oldest queued message — or, in "all" mode, every queued message — into the main
436
+ * context store and the in-memory history. The moves run in one transaction, so a message
437
+ * is never durable in both stores or neither, and memory changes only after the commit.
438
+ */
439
+ async #consumeQueue(queue, mode) {
440
+ return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
441
+ if (queue.length === 0)
442
+ return false;
443
+ const count = mode === "all" ? queue.length : 1;
444
+ const batch = queue.slice(0, count);
445
+ // Settings carried by the consumed messages become the effective settings for the
446
+ // inference that follows, each defined field superseding the previous value. The
447
+ // effective values are persisted alongside the consumption so a restart keeps them.
448
+ let provider = this.#providerId;
449
+ let model = this.#model;
450
+ let effort = this.#effort;
451
+ let serviceTier = this.#serviceTier;
452
+ let changed = false;
453
+ for (const entry of batch) {
454
+ if (entry.options.provider !== undefined) {
455
+ provider = entry.options.provider;
456
+ changed = true;
457
+ }
458
+ if (entry.options.model !== undefined) {
459
+ model = entry.options.model;
460
+ changed = true;
461
+ }
462
+ if (entry.options.effort !== undefined) {
463
+ effort = entry.options.effort;
464
+ changed = true;
465
+ }
466
+ if (entry.options.serviceTier !== undefined) {
467
+ serviceTier = entry.options.serviceTier;
468
+ changed = true;
469
+ }
470
+ }
471
+ // A provider or model change is checked against the provider-model compatibility
472
+ // matrix. An incompatible change resets the conversation: the history is erased
473
+ // completely, the old provider session is destroyed, and the `modelChanged` hook
474
+ // may inject one handoff system message at the very beginning of the fresh
475
+ // context. A compatible provider change keeps the history but still gets a fresh
476
+ // session, since a session is bound to the provider that created it.
477
+ const selectionChanged = provider !== this.#providerId || model !== this.#model;
478
+ let reset = false;
479
+ let injected;
480
+ if (selectionChanged) {
481
+ if (this.#model !== undefined && model !== undefined) {
482
+ const previousType = this.#providers.typeOf(this.#providerId);
483
+ const nextType = this.#providers.typeOf(provider);
484
+ reset =
485
+ previousType === null ||
486
+ nextType === null ||
487
+ !areProviderModelsCompatible({
488
+ modelId: this.#model,
489
+ providerId: this.#providerId,
490
+ providerType: previousType,
491
+ }, {
492
+ modelId: model,
493
+ providerId: provider,
494
+ providerType: nextType,
495
+ });
496
+ }
497
+ else {
498
+ // A selection without a model on either side cannot be judged compatible.
499
+ reset = model !== this.#model;
500
+ }
501
+ if (this.#hooks.modelChanged !== undefined && model !== undefined) {
502
+ const changeCtx = withAgentBaseContext(this.#baseCtx, {
503
+ provider,
504
+ model,
505
+ effort,
506
+ serviceTier,
507
+ });
508
+ try {
509
+ injected = this.#hooks.modelChanged(changeCtx, {
510
+ previousModel: this.#model,
511
+ model,
512
+ previousProvider: this.#providerId,
513
+ provider,
514
+ providers: this.#providers,
515
+ previousProviderInstance: this.#providers.get(this.#providerId),
516
+ providerInstance: this.#providers.get(provider),
517
+ wasReset: reset,
518
+ });
519
+ }
520
+ catch {
521
+ // Hooks observe the run; they never fail it.
522
+ }
523
+ if (!reset)
524
+ injected = undefined;
525
+ }
526
+ }
527
+ await this.#persistence.transaction(lockCtx, async (txCtx) => {
528
+ if (reset) {
529
+ await this.#persistence.clearRecords(txCtx);
530
+ if (injected !== undefined) {
531
+ await this.#persistence.append(txCtx, {
532
+ type: "system",
533
+ message: injected,
534
+ });
535
+ }
536
+ }
537
+ for (const entry of batch) {
538
+ await this.#persistence.append(txCtx, {
539
+ type: "user",
540
+ message: entry.message,
541
+ });
542
+ await this.#persistence.deleteValue(txCtx, entry.key);
543
+ }
544
+ if (changed) {
545
+ await this.#persistence.writeValue(txCtx, "settings", {
546
+ provider,
547
+ ...(model === undefined ? {} : { model }),
548
+ ...(effort === undefined ? {} : { effort }),
549
+ ...(serviceTier === undefined ? {} : { serviceTier }),
550
+ });
551
+ }
552
+ });
553
+ queue.splice(0, count);
554
+ if (reset) {
555
+ this.#messages = injected === undefined ? [] : [injected];
556
+ }
557
+ if (reset || provider !== this.#providerId) {
558
+ const session = this.#session;
559
+ this.#session = undefined;
560
+ try {
561
+ await session?.destroy();
562
+ }
563
+ catch {
564
+ // The change already committed; a failing destroy must not undo it.
565
+ }
566
+ }
567
+ this.#messages.push(...batch.map((entry) => entry.message));
568
+ if (changed) {
569
+ this.#providerId = provider;
570
+ this.#model = model;
571
+ this.#effort = effort;
572
+ this.#serviceTier = serviceTier;
573
+ this.#ctx = this.#deriveCtx();
574
+ }
575
+ return true;
576
+ });
577
+ }
578
+ /**
579
+ * Replace the in-memory state with the durable one. The persistence lock guarantees every
580
+ * message already in memory reached storage first, so the load result supersedes memory
581
+ * entirely: the main store rebuilds the context, and the sorted queue keys rebuild the
582
+ * not-yet-consumed queues. Consecutive block records reassemble into one assistant message.
583
+ */
584
+ async #loadHistory() {
585
+ await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
586
+ const records = await this.#persistence.load(lockCtx);
587
+ let restored = [];
588
+ for (const record of records) {
589
+ if (record.type === "compaction") {
590
+ // A compaction record carries the complete replacement context and
591
+ // supersedes everything before it.
592
+ restored = [...record.messages];
593
+ continue;
594
+ }
595
+ if (record.type === "user" || record.type === "tool" || record.type === "system") {
596
+ restored.push(record.message);
597
+ continue;
598
+ }
599
+ const last = restored[restored.length - 1];
600
+ if (last?.role === "assistant") {
601
+ restored[restored.length - 1] = {
602
+ role: "assistant",
603
+ content: [...last.content, record.block],
604
+ };
605
+ }
606
+ else {
607
+ restored.push({ role: "assistant", content: [record.block] });
608
+ }
609
+ }
610
+ const steering = await this.#persistence.readValues(lockCtx, "steering.");
611
+ const sends = await this.#persistence.readValues(lockCtx, "send.");
612
+ const pendingTools = await this.#persistence.readValues(lockCtx, "tool.");
613
+ const settings = await this.#persistence.readValues(lockCtx, "settings");
614
+ this.#messages = restored;
615
+ const entry = (key, value) => {
616
+ const envelope = value;
617
+ return { key, message: envelope.message, options: envelope.options ?? {} };
618
+ };
619
+ this.#steering = steering.map(({ key, value }) => entry(key, value));
620
+ this.#sends = sends.map(({ key, value }) => entry(key, value));
621
+ // The persisted settings are the complete effective triple from the last change; an
622
+ // absent field means that setting was effectively unset when it was written.
623
+ const persisted = settings[0]?.value;
624
+ if (persisted !== undefined) {
625
+ if (persisted.provider !== undefined)
626
+ this.#providerId = persisted.provider;
627
+ this.#model = persisted.model;
628
+ this.#effort = persisted.effort;
629
+ this.#serviceTier = persisted.serviceTier;
630
+ this.#ctx = this.#deriveCtx();
631
+ }
632
+ this.#pendingTools = pendingTools.map(({ key, value }) => ({
633
+ key,
634
+ call: value,
635
+ }));
636
+ });
637
+ }
638
+ /**
639
+ * Run one batch of tool calls. The whole batch is committed to the sorted store before any
640
+ * call executes, so a crash mid-batch leaves a durable record of the calls still owed a
641
+ * result. All calls run in parallel, but results land strictly in call order: a finished
642
+ * result waits until every earlier call in the batch has committed, and each commit appends
643
+ * the tool record and removes the pending entry in one transaction before memory changes.
644
+ * On resume, only durable tools execute again; the rest become error results. An abort
645
+ * settles every call still running as an aborted error result, so the batch always leaves a
646
+ * complete context behind.
647
+ */
648
+ async #runToolBatch(entries, resume, signal, abortPromise) {
649
+ if (!resume) {
650
+ await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#persistence.transaction(lockCtx, async (txCtx) => {
651
+ for (const entry of entries) {
652
+ await this.#persistence.writeValue(txCtx, entry.key, entry.call);
653
+ }
654
+ }));
655
+ }
656
+ const results = new Array(entries.length);
657
+ let committed = 0;
658
+ const commitReady = () => this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
659
+ while (committed < entries.length) {
660
+ const entry = entries[committed];
661
+ const result = results[committed];
662
+ if (entry === undefined || result === undefined)
663
+ return;
664
+ await this.#persistence.transaction(lockCtx, async (txCtx) => {
665
+ await this.#persistence.append(txCtx, {
666
+ type: "tool",
667
+ message: result,
668
+ });
669
+ await this.#persistence.deleteValue(txCtx, entry.key);
670
+ });
671
+ this.#messages.push(result);
672
+ committed += 1;
673
+ }
674
+ });
675
+ await Promise.all(entries.map(async (entry, index) => {
676
+ const outcome = resume && !this.#isDurable(entry.call)
677
+ ? {
678
+ role: "tool",
679
+ callId: entry.call.callId,
680
+ content: [
681
+ {
682
+ type: "text",
683
+ text: "The tool call was interrupted by a restart and was not retried.",
684
+ },
685
+ ],
686
+ isError: true,
687
+ }
688
+ : await Promise.race([
689
+ this.#executeToolCall(withLifetime(this.#ctx, signal), entry.call),
690
+ abortPromise,
691
+ ]);
692
+ results[index] =
693
+ outcome === ABORTED
694
+ ? {
695
+ role: "tool",
696
+ callId: entry.call.callId,
697
+ content: [{ type: "text", text: "The tool call was aborted." }],
698
+ isError: true,
699
+ }
700
+ : outcome;
701
+ await commitReady();
702
+ }));
703
+ }
704
+ #isDurable(call) {
705
+ const tool = this.#tools().find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
706
+ return tool?.durable === true;
707
+ }
708
+ /** Sorted by position in the batch; only one batch is ever pending at a time. */
709
+ #toolKey(index, callId) {
710
+ return `tool.${String(index).padStart(6, "0")}.${callId}`;
711
+ }
712
+ /**
713
+ * Run one tool call; every failure becomes an error tool result instead of an exception.
714
+ * The context carries the turn's abort signal as its lifetime, so a running tool can
715
+ * observe cancellation and stop its own work.
716
+ */
717
+ async #executeToolCall(ctx, call) {
718
+ const failure = (text) => ({
719
+ role: "tool",
720
+ callId: call.callId,
721
+ content: [{ type: "text", text }],
722
+ isError: true,
723
+ });
724
+ const tool = this.#tools().find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
725
+ if (tool === undefined) {
726
+ return failure(`Tool "${call.name}" is not available.`);
727
+ }
728
+ if (call.incomplete === true) {
729
+ return failure("The tool call was incomplete and was not executed.");
730
+ }
731
+ let args;
732
+ try {
733
+ args = call.arguments.trim().length === 0 ? {} : JSON.parse(call.arguments);
734
+ }
735
+ catch {
736
+ return failure(`The arguments for "${call.name}" were not valid JSON.`);
737
+ }
738
+ if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
739
+ return failure(`The arguments for "${call.name}" did not match its schema.`);
740
+ }
741
+ try {
742
+ const result = await tool.execute(ctx, args);
743
+ if (!Value.Check(tool.returnType, result)) {
744
+ return failure(`Tool "${call.name}" returned an invalid result.`);
745
+ }
746
+ const content = tool.toLLM(result);
747
+ const isError = tool.isError?.(result) === true;
748
+ return {
749
+ role: "tool",
750
+ callId: call.callId,
751
+ content: [...content],
752
+ ...(isError ? { isError: true } : {}),
753
+ };
754
+ }
755
+ catch (error) {
756
+ return failure(error instanceof Error ? error.message : String(error));
757
+ }
758
+ }
759
+ /** Sorted after every earlier key of its queue, within this process and across restarts. */
760
+ #queueKey(prefix) {
761
+ const time = String(Date.now()).padStart(14, "0");
762
+ const sequence = String(this.#pendingSequence++).padStart(6, "0");
763
+ return `${prefix}${time}.${sequence}`;
764
+ }
765
+ async #collect(stream, abortPromise) {
766
+ const content = [];
767
+ // Blocks that finished and were durably appended. An abort keeps exactly these, so the
768
+ // in-memory assistant message never diverges from what a reload would rebuild.
769
+ const persisted = [];
770
+ const toolCallIndexes = new Map();
771
+ const persist = async (block) => {
772
+ if (block === undefined)
773
+ return;
774
+ await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#persistence.append(lockCtx, { type: "block", block }));
775
+ persisted.push(block);
776
+ };
777
+ const iterator = stream[Symbol.asyncIterator]();
778
+ while (true) {
779
+ const next = await Promise.race([iterator.next(), abortPromise]);
780
+ if (next === ABORTED) {
781
+ // Close the provider stream, drop the unfinished block, and end the turn.
782
+ void Promise.resolve(iterator.return?.()).catch(() => undefined);
783
+ this.#emit({ type: "done", state: "cancelled" });
784
+ return { content: persisted, state: "cancelled" };
785
+ }
786
+ if (next.done === true)
787
+ break;
788
+ const event = next.value;
789
+ this.#emit(event);
790
+ switch (event.type) {
791
+ case "text_start":
792
+ content.push({ type: "text", text: "" });
793
+ break;
794
+ case "text_delta": {
795
+ const last = content[content.length - 1];
796
+ if (last?.type === "text") {
797
+ content[content.length - 1] = {
798
+ type: "text",
799
+ text: last.text + event.delta,
800
+ };
801
+ }
802
+ break;
803
+ }
804
+ case "text_end": {
805
+ const last = content[content.length - 1];
806
+ await persist(last?.type === "text" ? last : undefined);
807
+ break;
808
+ }
809
+ case "reasoning_start":
810
+ content.push({ type: "reasoning", text: "" });
811
+ break;
812
+ case "reasoning_delta": {
813
+ const last = content[content.length - 1];
814
+ if (last?.type === "reasoning") {
815
+ content[content.length - 1] = {
816
+ ...last,
817
+ text: (last.text ?? "") + event.delta,
818
+ };
819
+ }
820
+ break;
821
+ }
822
+ case "reasoning_end": {
823
+ const last = content[content.length - 1];
824
+ if (last?.type === "reasoning") {
825
+ const finished = {
826
+ ...last,
827
+ ...(event.reasoning === undefined
828
+ ? {}
829
+ : { reasoning: event.reasoning }),
830
+ };
831
+ content[content.length - 1] = finished;
832
+ await persist(finished);
833
+ }
834
+ break;
835
+ }
836
+ case "toolcall_start":
837
+ toolCallIndexes.set(event.callId, content.length);
838
+ content.push({
839
+ type: "tool_call",
840
+ callId: event.callId,
841
+ name: event.name,
842
+ arguments: "",
843
+ ...(event.namespace === undefined ? {} : { namespace: event.namespace }),
844
+ ...(event.server === undefined ? {} : { server: event.server }),
845
+ ...(event.vendor === undefined ? {} : { vendor: event.vendor }),
846
+ });
847
+ break;
848
+ case "toolcall_end": {
849
+ const index = toolCallIndexes.get(event.callId);
850
+ const block = index === undefined ? undefined : content[index];
851
+ if (index !== undefined && block?.type === "tool_call") {
852
+ const finished = {
853
+ ...block,
854
+ arguments: event.arguments,
855
+ ...(event.incomplete === undefined
856
+ ? {}
857
+ : { incomplete: event.incomplete }),
858
+ };
859
+ content[index] = finished;
860
+ await persist(finished);
861
+ }
862
+ break;
863
+ }
864
+ // The provider settled a server tool call on its own backend and streams the
865
+ // result here. The agent simply ignores it: nothing to execute, nothing to
866
+ // store — the events still reach the hooks like every other event.
867
+ case "toolcall_result_start":
868
+ case "toolcall_result_delta":
869
+ case "toolcall_result_end":
870
+ break;
871
+ case "done":
872
+ return {
873
+ content,
874
+ state: event.state,
875
+ ...(event.state === "error" ? { errorMessage: event.message } : {}),
876
+ };
877
+ default:
878
+ break;
879
+ }
880
+ }
881
+ return { content, state: undefined };
882
+ }
883
+ /**
884
+ * Create the provider session on first use, resolving the provider from the registry by its
885
+ * serializable ID at that moment; an unregistered ID fails the turn like any thrown error.
886
+ */
887
+ async #ensureSession() {
888
+ if (this.#session === undefined) {
889
+ const provider = this.#providers.get(this.#providerId);
890
+ if (provider === null) {
891
+ throw new Error(`Provider "${this.#providerId}" is not registered.`);
892
+ }
893
+ this.#session = await provider.session(this.id, {
894
+ instructions: this.#instructions(),
895
+ tools: [...this.#tools()],
896
+ });
897
+ }
898
+ return this.#session;
899
+ }
900
+ #emit(event) {
901
+ try {
902
+ this.#hooks.onEvent?.(this.#ctx, event);
903
+ }
904
+ catch {
905
+ // Hooks observe the stream; they never fail a run.
906
+ }
907
+ }
908
+ }
909
+ //# sourceMappingURL=AgentBase.js.map