@slopus/happy-agent-base 0.0.2 → 0.0.4

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 (46) hide show
  1. package/README.md +20 -635
  2. package/dist/Agent.d.ts +7 -0
  3. package/dist/Agent.d.ts.map +1 -1
  4. package/dist/Agent.js +20 -2
  5. package/dist/Agent.js.map +1 -1
  6. package/dist/AgentBase.d.ts +15 -17
  7. package/dist/AgentBase.d.ts.map +1 -1
  8. package/dist/AgentBase.js +237 -270
  9. package/dist/AgentBase.js.map +1 -1
  10. package/dist/AgentBaseHooks.d.ts +43 -5
  11. package/dist/AgentBaseHooks.d.ts.map +1 -1
  12. package/dist/AgentBasePending.d.ts +3 -8
  13. package/dist/AgentBasePending.d.ts.map +1 -1
  14. package/dist/AgentBasePending.js +4 -13
  15. package/dist/AgentBasePending.js.map +1 -1
  16. package/dist/AgentFeature.d.ts +29 -2
  17. package/dist/AgentFeature.d.ts.map +1 -1
  18. package/dist/AgentKV.d.ts +0 -5
  19. package/dist/AgentKV.d.ts.map +1 -1
  20. package/dist/AgentKV.js +0 -8
  21. package/dist/AgentKV.js.map +1 -1
  22. package/dist/AgentPersistence.d.ts +0 -30
  23. package/dist/AgentPersistence.d.ts.map +1 -1
  24. package/dist/AgentProviders.d.ts +19 -9
  25. package/dist/AgentProviders.d.ts.map +1 -1
  26. package/dist/AgentProviders.js +15 -12
  27. package/dist/AgentProviders.js.map +1 -1
  28. package/dist/AgentStorage.d.ts +20 -0
  29. package/dist/AgentStorage.d.ts.map +1 -1
  30. package/dist/AgentStorage.js +33 -0
  31. package/dist/AgentStorage.js.map +1 -1
  32. package/dist/AgentSystem.d.ts +2 -0
  33. package/dist/AgentSystem.d.ts.map +1 -1
  34. package/dist/AgentSystemLocal.d.ts +12 -11
  35. package/dist/AgentSystemLocal.d.ts.map +1 -1
  36. package/dist/AgentSystemLocal.js +186 -67
  37. package/dist/AgentSystemLocal.js.map +1 -1
  38. package/dist/index.d.ts +3 -3
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -2
  41. package/dist/index.js.map +1 -1
  42. package/package.json +2 -2
  43. package/dist/AgentBaseStoreLock.d.ts +0 -16
  44. package/dist/AgentBaseStoreLock.d.ts.map +0 -1
  45. package/dist/AgentBaseStoreLock.js +0 -37
  46. package/dist/AgentBaseStoreLock.js.map +0 -1
package/dist/AgentBase.js CHANGED
@@ -1,13 +1,11 @@
1
1
  import { areProviderModelsCompatible } from "@slopus/happy-providers";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { randomUUID } from "node:crypto";
4
3
  import { Value } from "@sinclair/typebox/value";
5
4
  import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
6
5
  import { withAgentContext, withAgentKV, withAgentRunKV } from "./AgentContexts.js";
7
6
  import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
8
7
  import { AgentKV } from "./AgentKV.js";
9
8
  import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
10
- import { agentBaseStoreLock, agentBaseWithStoreStill } from "./AgentBaseStoreLock.js";
11
9
  import { AgentProviders } from "./AgentProviders.js";
12
10
  /** Race winner when an abort interrupts a wait on the stream or a running tool. */
13
11
  const ABORTED = Symbol("aborted");
@@ -32,8 +30,6 @@ const insideLoops = new AsyncLocalStorage();
32
30
  * hears the shutdown finish, short enough that one still holding the loop is told promptly.
33
31
  */
34
32
  const INSIDE_CLOSE_REPORT_MS = 15;
35
- /** Rolls a consumption back when every entry in its batch was already taken by another owner. */
36
- const LOST_QUEUE_RACE = Symbol("lostQueueRace");
37
33
  /**
38
34
  * A single agent session over one provider. Messages arrive through two FIFO queues: steering
39
35
  * messages inject as soon as the current assistant response and its tool batch finish, while
@@ -43,8 +39,7 @@ const LOST_QUEUE_RACE = Symbol("lostQueueRace");
43
39
  *
44
40
  * The rest of this comment is the list of promises the implementation has to keep. They are
45
41
  * written down because most of them are invisible in ordinary use and only show themselves when
46
- * a process dies, two owners share a store, or a caller races the loop every one of them was
47
- * bought with a bug found by `tests/chaos/`, and each has a focused test that fails without it.
42
+ * a process dies or a caller races the loop. Each has focused test coverage.
48
43
  *
49
44
  * ## Serialization
50
45
  *
@@ -63,24 +58,19 @@ const LOST_QUEUE_RACE = Symbol("lostQueueRace");
63
58
  * acceptance is the same one, and a close still waits for it.
64
59
  * - Messages a hook returns from one decision are accepted as one batch. A caller arriving while
65
60
  * that batch is being written lands after all of it, never between two halves of one thought.
66
- * - Queue keys order by what the store already holds and end in a segment identifying their
67
- * writer, so two owners accepting in the same millisecond may order arbitrarily but can never
68
- * overwrite one another.
61
+ * - Queue keys order by what the store already holds, including when the clock moves backwards.
69
62
  * - An agent holding a durable message never describes itself as settled.
70
63
  *
71
64
  * ## Consuming a message
72
65
  *
73
- * A consumption claims each entry with an atomic delete inside its own transaction, so one
74
- * durable message is answered exactly once however many live owners hold it in memory. A batch
75
- * that claims nothing rolls back having changed nothing. A message is never durable in both the
76
- * queue and the context, or in neither, and memory changes only after the commit.
66
+ * A consumption deletes each entry inside the transaction that appends it to the context. A
67
+ * message is never durable in both places, or in neither, and memory changes only after commit.
77
68
  *
78
69
  * ## Turns
79
70
  *
80
- * A turn answers the durable conversation, not the one this instance remembers: it reloads
81
- * before it decides anything, so an appended message or a model switch from another owner is in
82
- * force by the next turn. A turn that consumes the last queued work clears the request it just
83
- * answered, rather than buying an extra turn with an empty queue and a full set of hooks.
71
+ * A turn reloads the durable conversation before it decides anything, keeping the store
72
+ * authoritative across restarts. A turn that consumes the last queued work clears the request it
73
+ * just answered rather than buying an extra empty turn with a full set of hooks.
84
74
  *
85
75
  * ## Tool calls
86
76
  *
@@ -102,10 +92,8 @@ const LOST_QUEUE_RACE = Symbol("lostQueueRace");
102
92
  * ## Compaction
103
93
  *
104
94
  * A compaction runs before a turn's first inference, so the model always receives a settled
105
- * conversation. It replaces the history whole or not at all, and the suffix it preserves is
106
- * rebuilt from the store inside the commit — from a record count taken at the snapshot, so work
107
- * another owner committed while the provider was summarizing survives. A compaction nobody will
108
- * carry out is rejected rather than left waiting.
95
+ * conversation. It replaces the history whole or not at all. A compaction nobody will carry out
96
+ * is rejected rather than left waiting.
109
97
  *
110
98
  * ## Model changes
111
99
  *
@@ -255,13 +243,6 @@ export class AgentBase {
255
243
  #toolsRunning = 0;
256
244
  /** The in-flight or finished load of the durable state; cleared at the start of every turn. */
257
245
  #loaded;
258
- /**
259
- * Identifies this instance's writes, so no other writer can produce one of its keys. It is a
260
- * UUID rather than a number drawn from the general-purpose generator, because two owners of
261
- * one store acknowledging a message each are relying on it to keep their keys apart, and a
262
- * generator that can be seeded — or replaced — would let both of them claim the same one.
263
- */
264
- #writer = randomUUID();
265
246
  /**
266
247
  * The kind of the last durable record, which says what the conversation is waiting for far
267
248
  * more precisely than the message it ends on: a consumed message, a tool result or the note
@@ -269,19 +250,6 @@ export class AgentBase {
269
250
  * compaction is owed nothing at all.
270
251
  */
271
252
  #lastRecordType;
272
- /**
273
- * Whether that last record was a replacement that ended on a message still owed an answer.
274
- * Only the rewrite that wrote it can tell a summary's own last message from a suffix it kept.
275
- */
276
- #lastRecordContinuesInference = false;
277
- /**
278
- * How many durable records the in-memory conversation accounts for: the ones it was loaded
279
- * from, plus every one this instance has appended since. A rewrite replaces exactly those.
280
- * Counting the store afresh would treat records this instance has never seen as already
281
- * summarized and erase them; forgetting its own appends would carry records the summary
282
- * already covers into the replacement a second time.
283
- */
284
- #loadedRecordCount = 0;
285
253
  /**
286
254
  * Whether this instance has checked whether a cut-off run should resume inference. The
287
255
  * question is only meaningful once, against the state the agent first loaded: afterwards a
@@ -424,7 +392,7 @@ export class AgentBase {
424
392
  this.#providers = options.providers;
425
393
  this.#providerId = options.provider;
426
394
  this.#persistence = options.persistence;
427
- this.#persistenceLock = agentBaseStoreLock(options.persistence);
395
+ this.#persistenceLock = asyncLock({ reentry: "block" });
428
396
  this.#hooks = options.hooks ?? {};
429
397
  this.state = {
430
398
  instructions: options.initialState?.instructions ?? "",
@@ -471,9 +439,9 @@ export class AgentBase {
471
439
  * whatever else that transaction is writing, which is how a consumed message and the
472
440
  * inference it owes become durable as one fact rather than two.
473
441
  */
474
- async #recordPending(ctx, pending) {
442
+ async #recordPending(ctx, pending, force = false) {
475
443
  const serialized = deterministicStringify(pending);
476
- if (this.#pendingWritten === serialized)
444
+ if (!force && this.#pendingWritten === serialized)
477
445
  return;
478
446
  await this.#persistence.writeValue(ctx, AGENT_BASE_PENDING_KEY, pending);
479
447
  this.#pending = pending;
@@ -487,22 +455,38 @@ export class AgentBase {
487
455
  * is doing, and a run interrupted by a dead process would be indistinguishable from the one
488
456
  * starting here.
489
457
  */
490
- async #enterStage(stage) {
458
+ async #enterStage(stage, transact) {
491
459
  const pending = { stage };
492
- if (deterministicStringify(pending) === this.#pendingWritten && this.#inheritedRead)
493
- return;
460
+ if (transact === undefined &&
461
+ deterministicStringify(pending) === this.#pendingWritten &&
462
+ this.#inheritedRead) {
463
+ return undefined;
464
+ }
494
465
  try {
495
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
466
+ return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
496
467
  if (!this.#inheritedRead) {
497
468
  this.#inheritedRead = true;
498
469
  this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
499
470
  }
500
- await this.#recordPending(lockCtx, pending);
471
+ if (transact === undefined) {
472
+ await this.#recordPending(lockCtx, pending);
473
+ return undefined;
474
+ }
475
+ return await this.#recordTransaction(lockCtx, async (txCtx) => {
476
+ // The state is staged first. The callback then writes against this exact
477
+ // transaction, so neither its conclusion nor the state it observed can land
478
+ // without the other.
479
+ await this.#recordPending(txCtx, pending, true);
480
+ return await this.#withTransactionalContext(txCtx, transact);
481
+ });
501
482
  });
502
483
  }
503
- catch {
484
+ catch (error) {
485
+ if (transact !== undefined)
486
+ throw error;
504
487
  // Losing the record costs recovery precision, never the work itself: the turn is
505
488
  // already running and will answer whatever it was going to answer.
489
+ return undefined;
506
490
  }
507
491
  }
508
492
  /**
@@ -871,12 +855,27 @@ export class AgentBase {
871
855
  }),
872
856
  ]);
873
857
  if (!settled) {
858
+ // The caller cannot wait for its own turn, but the agent is closing all the same.
859
+ // Revoke the caller's tool/store capability before reporting the cyclic wait so it
860
+ // cannot resume later and write after the owning system releases its store lock.
861
+ this.#closeController.abort();
874
862
  throw new Error("Closing the agent from inside its own run loop would wait for a turn that " +
875
863
  "cannot finish. The shutdown was started and will complete once this " +
876
864
  "caller returns.");
877
865
  }
878
866
  await this.#closing;
879
867
  }
868
+ /**
869
+ * Wait for a close that has already been requested to finish. Unlike `close`, this never
870
+ * initiates shutdown, so owners can separate the caller-facing reentrancy report from the
871
+ * underlying lifetime barrier.
872
+ */
873
+ async waitForClosed() {
874
+ const closing = this.#closing;
875
+ if (closing === undefined)
876
+ throw new Error("The agent has not been asked to close.");
877
+ await closing;
878
+ }
880
879
  /**
881
880
  * Make sure the run loop is running. A loop already in flight picks up the request on its
882
881
  * next pass, so this never starts a second one.
@@ -918,7 +917,7 @@ export class AgentBase {
918
917
  // crash could interrupt. What it records is refined as the run reaches each stage;
919
918
  // what matters at this point is that the record exists at all, since its absence is
920
919
  // what a later process reads as an agent that finished.
921
- await this.#enterStage("inference");
920
+ await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact);
922
921
  await this.#invokeHook(this.#hooks.beforeAgentLoop);
923
922
  do {
924
923
  this.#turnAborted = false;
@@ -927,10 +926,8 @@ export class AgentBase {
927
926
  // redundant turn this can cost is cheap: an empty queue drains without any
928
927
  // inference.
929
928
  this.#turnRequested = false;
930
- // Every turn starts from the durable state rather than from what this instance
931
- // last remembered. Another owner over the same store may have appended messages
932
- // or changed the selection since, and answering out of a stale memory would
933
- // reply to a conversation that no longer exists.
929
+ // Every turn starts from durable state rather than from what this instance last
930
+ // remembered, so the store remains authoritative after recovery.
934
931
  this.#loaded = undefined;
935
932
  // The durable history has to be loaded before anything else: a turn that cannot
936
933
  // read the conversation cannot answer it, and must not write to it either —
@@ -949,20 +946,29 @@ export class AgentBase {
949
946
  });
950
947
  break;
951
948
  }
952
- await this.#applyActions(this.#hooks.beforeTurn, abort.signal, {
949
+ const turnStart = {
953
950
  contextTokens: this.#contextTokens,
954
- });
951
+ };
952
+ await this.#enterStage("inference", this.#hooks.beforeTurnTransact === undefined
953
+ ? undefined
954
+ : (hookCtx) => this.#hooks.beforeTurnTransact?.(hookCtx, turnStart));
955
+ await this.#applyActions(this.#hooks.beforeTurn, abort.signal, turnStart);
955
956
  await this.#runInference(abort);
956
- await this.#applyActions(this.#hooks.afterTurn, abort.signal, {
957
+ const turn = {
957
958
  contextTokens: this.#contextTokens,
958
959
  aborted: this.#turnAborted,
959
- });
960
+ };
961
+ await this.#enterStage("inference", this.#hooks.afterTurnTransact === undefined
962
+ ? undefined
963
+ : (hookCtx) => this.#hooks.afterTurnTransact?.(hookCtx, turn));
964
+ await this.#applyActions(this.#hooks.afterTurn, abort.signal, turn);
960
965
  if (!this.#turnRequested || this.#closed)
961
966
  break;
962
967
  // Each turn cancels on its own scope. Reopening it here rather than at the top
963
968
  // keeps the run's first turn under the scope its opening hook already ran in.
964
969
  abort = this.#openAbortScope();
965
970
  } while (true);
971
+ await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact);
966
972
  await this.#applyActions(this.#hooks.afterAgentLoop, abort.signal);
967
973
  } while (this.#turnRequested && !this.#closed);
968
974
  // Nothing is asked for any more, so the outstanding work is erased. That erasure is what
@@ -979,7 +985,7 @@ export class AgentBase {
979
985
  */
980
986
  async #settleDurably() {
981
987
  try {
982
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#persistence.transaction(lockCtx, async (txCtx) => {
988
+ await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
983
989
  await this.#clearPending(txCtx);
984
990
  await this.#invokeTransactionalSettle(txCtx);
985
991
  // The run store is erased last, so a settling hook can still read what the
@@ -1004,15 +1010,7 @@ export class AgentBase {
1004
1010
  const hook = this.#hooks.afterAgentSettledTransact;
1005
1011
  if (hook === undefined)
1006
1012
  return;
1007
- const committed = new AbortController();
1008
- try {
1009
- const liveCtx = withLifetime(insideTurn.set(txCtx, []), committed.signal);
1010
- const hookCtx = withAgentKV(liveCtx, this.#kv);
1011
- await hook(withAgentRunKV(hookCtx, this.#runKV));
1012
- }
1013
- finally {
1014
- committed.abort();
1015
- }
1013
+ await this.#withTransactionalContext(insideTurn.set(txCtx, []), hook);
1016
1014
  }
1017
1015
  /**
1018
1016
  * Erase everything the run wrote about itself, inside the transaction that settles the agent:
@@ -1063,6 +1061,21 @@ export class AgentBase {
1063
1061
  async #invokeHook(hook, ...args) {
1064
1062
  await this.#invokeHookOn(this.#ctx, hook, ...args);
1065
1063
  }
1064
+ /**
1065
+ * Lend a hook the transaction's context and feature stores for exactly one callback. Keeping
1066
+ * the context after the callback cannot leak a transaction past its commit.
1067
+ */
1068
+ async #withTransactionalContext(txCtx, work) {
1069
+ const lifetime = new AbortController();
1070
+ try {
1071
+ const liveCtx = withLifetime(txCtx, lifetime.signal);
1072
+ const hookCtx = withAgentKV(liveCtx, this.#kv);
1073
+ return await work(withAgentRunKV(hookCtx, this.#runKV));
1074
+ }
1075
+ finally {
1076
+ lifetime.abort();
1077
+ }
1078
+ }
1066
1079
  /**
1067
1080
  * Ask a hook what to do next, on a scope that may be cancelled while the hook is still
1068
1081
  * thinking. An abort owns the whole of the turn it cancelled, including the answer of a hook
@@ -1212,6 +1225,7 @@ export class AgentBase {
1212
1225
  // either.
1213
1226
  if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
1214
1227
  continue;
1228
+ await this.#enterStage("inference", this.#hooks.beforeInferenceTransact);
1215
1229
  await this.#invokeHook(this.#hooks.beforeInference);
1216
1230
  const stream = session.run(this.#ctx, {
1217
1231
  context: {
@@ -1225,14 +1239,21 @@ export class AgentBase {
1225
1239
  const { content, state, errorMessage, tokens } = await this.#collect(stream, abortPromise);
1226
1240
  // A cancelled or failed response measures nothing, so the conversation keeps
1227
1241
  // the last real measurement instead of forgetting how large it had become.
1228
- if (tokens !== undefined) {
1229
- await this.#recordContextTokens(tokens.input + tokens.output);
1230
- }
1231
- await this.#invokeHook(this.#hooks.afterInference, {
1242
+ const inference = {
1232
1243
  state,
1233
1244
  tokens,
1234
1245
  ...(errorMessage === undefined ? {} : { errorMessage }),
1235
- });
1246
+ };
1247
+ const afterInferenceTransact = this.#hooks.afterInferenceTransact === undefined
1248
+ ? undefined
1249
+ : (hookCtx) => this.#hooks.afterInferenceTransact?.(hookCtx, inference);
1250
+ if (tokens === undefined) {
1251
+ await this.#enterStage("inference", afterInferenceTransact);
1252
+ }
1253
+ else {
1254
+ await this.#recordContextTokens(tokens.input + tokens.output, afterInferenceTransact);
1255
+ }
1256
+ await this.#invokeHook(this.#hooks.afterInference, inference);
1236
1257
  if (content.length > 0) {
1237
1258
  this.#messages.push({ role: "assistant", content });
1238
1259
  }
@@ -1298,26 +1319,15 @@ export class AgentBase {
1298
1319
  * and so owes an inference nobody asked for again.
1299
1320
  *
1300
1321
  * What is outstanding is read from the conversation: a tail that is a consumed message, a
1301
- * tool result, or the note a failed turn left behind is owed an answer, while a replacement
1302
- * written by a compaction is owed one only when the suffix it kept ends in a request.
1303
- *
1304
- * The pending record deliberately does not decide this, because one store may have several
1305
- * live owners and there is only one record. An owner working right now leaves behind exactly
1306
- * what a process that died would have left, so deciding from the record alone would have
1307
- * each owner treat the others' work as abandoned and answer it a second time. What the
1308
- * record adds is the knowledge that some run reached the model: a listener shown the
1322
+ * tool result, or the note a failed turn left behind is owed an answer. The pending record
1323
+ * adds the knowledge that the interrupted run reached the model: a listener shown the
1309
1324
  * beginning of a block that will now never arrive is told to drop it. Only finished blocks
1310
- * are ever persisted, so the conversation is intact and it is the view being corrected.
1325
+ * are persisted, so the conversation is intact and it is the view being corrected.
1311
1326
  */
1312
1327
  #resumesInterruptedRun() {
1313
1328
  const owed = this.#lastRecordType === "user" ||
1314
1329
  this.#lastRecordType === "tool" ||
1315
- this.#lastRecordType === "system" ||
1316
- // A replacement record is not a question in itself, however it happens to end — but
1317
- // it keeps the suffix that joined the conversation after its snapshot, and a consumed
1318
- // message in that suffix still needs inference. Which kind of message ends the
1319
- // replacement is not visible in the messages, so the rewrite that knew records it.
1320
- (this.#lastRecordType === "compaction" && this.#lastRecordContinuesInference);
1330
+ this.#lastRecordType === "system";
1321
1331
  if (owed && this.#inherited?.stage === "inference")
1322
1332
  this.#emit({ type: "block_reset" });
1323
1333
  return owed;
@@ -1335,24 +1345,39 @@ export class AgentBase {
1335
1345
  * lets a restarted agent keep knowing how large the conversation is without inferring it;
1336
1346
  * a failed write costs only that knowledge and never the response that produced it.
1337
1347
  */
1338
- async #recordContextTokens(tokens) {
1348
+ async #recordContextTokens(tokens, transact) {
1349
+ const previousTokens = this.#contextTokens;
1339
1350
  this.#contextTokens = tokens;
1340
1351
  try {
1341
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => tokens === undefined
1342
- ? this.#persistence.deleteValue(lockCtx, "context")
1343
- : this.#persistence.writeValue(lockCtx, "context", { tokens }));
1352
+ await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1353
+ const write = (writeCtx) => tokens === undefined
1354
+ ? this.#persistence.deleteValue(writeCtx, "context")
1355
+ : this.#persistence.writeValue(writeCtx, "context", { tokens });
1356
+ if (transact === undefined) {
1357
+ await write(lockCtx);
1358
+ return;
1359
+ }
1360
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
1361
+ await write(txCtx);
1362
+ await this.#withTransactionalContext(txCtx, transact);
1363
+ });
1364
+ });
1344
1365
  }
1345
- catch {
1346
- // A measurement is not worth failing a turn over; memory still carries it.
1366
+ catch (error) {
1367
+ // The prior committed measurement stays authoritative in memory. A transactional
1368
+ // observer is part of the same durable conclusion and therefore fails the turn when
1369
+ // that conclusion cannot commit; an ordinary best-effort measurement still does not.
1370
+ this.#contextTokens = previousTokens;
1371
+ if (transact !== undefined)
1372
+ throw error;
1347
1373
  }
1348
1374
  }
1349
1375
  /**
1350
- * Run the pending compaction, if any. The snapshot is taken before the turn's first
1351
- * inference, with this pass being the only history writer, so nothing joins the history
1352
- * mid-compaction; the suffix copy still keeps any such message, defensively. The replacement
1353
- * is appended as a compaction record the load-time reset point and settles the shared
1354
- * promise for every caller awaiting it. A provider failure rejects them and leaves the
1355
- * history untouched.
1376
+ * Run the pending compaction, if any. This pass is the only history writer, so the provider
1377
+ * summarizes exactly the conversation that the replacement supersedes. The replacement is
1378
+ * appended as a compaction record the load-time reset point and settles the shared
1379
+ * promise for every caller awaiting it. A provider failure rejects them and leaves history
1380
+ * untouched.
1356
1381
  */
1357
1382
  async #runCompaction(signal) {
1358
1383
  const pending = this.#compaction;
@@ -1362,15 +1387,6 @@ export class AgentBase {
1362
1387
  await this.#enterStage("compaction");
1363
1388
  const instructions = await this.#instructions();
1364
1389
  const session = await this.#ensureSession(instructions, await this.#tools());
1365
- // The snapshot is the durable conversation, counted as records: everything appended
1366
- // after this point is a suffix the replacement has to keep, whoever wrote it. Taking
1367
- // the boundary from the store rather than from this instance's own memory means a
1368
- // record another owner committed while the provider was summarizing survives the
1369
- // clear-and-replace instead of being erased by it.
1370
- // The boundary is the prefix this instance's memory was built from, not whatever
1371
- // the store holds now: the provider is about to summarize that memory, and counting
1372
- // a newer store would describe records it never saw as summarized.
1373
- const snapshotCount = this.#loadedRecordCount;
1374
1390
  const snapshot = [...this.#messages];
1375
1391
  await this.#settled();
1376
1392
  // Provider compaction is this turn's work, so it runs on this turn's lifetime: an
@@ -1385,27 +1401,17 @@ export class AgentBase {
1385
1401
  }
1386
1402
  if (result.status === "completed") {
1387
1403
  await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1388
- const records = await this.#persistence.load(lockCtx);
1389
- const suffix = messagesFromRecords(records.slice(snapshotCount));
1390
- const replaced = [...result.context.messages, ...suffix];
1391
- // Only a message in the live suffix can require another inference. The
1392
- // summary's own final message is provider-authored context, not a request.
1393
- const continuesInference = suffix.length > 0 && needsInference(replaced);
1394
1404
  // Physically delete the superseded records and write the replacement —
1395
1405
  // which keeps the messages that stay — in one atomic step.
1396
1406
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1397
1407
  await this.#persistence.clearRecords(txCtx);
1398
1408
  await this.#persistence.append(txCtx, {
1399
1409
  type: "compaction",
1400
- messages: replaced,
1401
- ...(continuesInference ? { continuesInference: true } : {}),
1410
+ messages: result.context.messages,
1402
1411
  });
1403
1412
  });
1404
- this.#messages = [...replaced];
1413
+ this.#messages = [...result.context.messages];
1405
1414
  this.#lastRecordType = "compaction";
1406
- this.#lastRecordContinuesInference = continuesInference;
1407
- // The store is now the one replacement record, and memory is exactly it.
1408
- this.#loadedRecordCount = 1;
1409
1415
  });
1410
1416
  // The conversation the measurement described is gone; its size is unknown
1411
1417
  // again until the next response measures the replacement.
@@ -1464,21 +1470,15 @@ export class AgentBase {
1464
1470
  return settled;
1465
1471
  }
1466
1472
  /**
1467
- * Append one record and keep count of it. Every record this instance writes is one more that
1468
- * its memory accounts for, and a rewrite has to know exactly where its own knowledge ends —
1469
- * so appending and counting are one step rather than two a caller could get out of order.
1473
+ * Append one record to the durable conversation.
1470
1474
  */
1471
1475
  async #appendRecord(ctx, record) {
1472
1476
  await this.#persistence.append(ctx, record);
1473
- this.#loadedRecordCount += 1;
1474
1477
  }
1475
1478
  /**
1476
- * A transaction whose effect on the record count unwinds with it. Records staged by a
1477
- * transaction that rolls back were never written, and memory never took them either, so the
1478
- * count must not go on claiming them.
1479
+ * A transaction whose pending-state cache unwinds with it.
1479
1480
  */
1480
1481
  async #recordTransaction(ctx, work) {
1481
- const counted = this.#loadedRecordCount;
1482
1482
  // The outstanding work unwinds with the records for the same reason: a stage staged by a
1483
1483
  // transaction that rolled back was never written, and memory claiming it would make the
1484
1484
  // agent skip the write that actually records what it is doing.
@@ -1488,7 +1488,6 @@ export class AgentBase {
1488
1488
  return await this.#persistence.transaction(ctx, work);
1489
1489
  }
1490
1490
  catch (error) {
1491
- this.#loadedRecordCount = counted;
1492
1491
  this.#pending = pending;
1493
1492
  this.#pendingWritten = written;
1494
1493
  throw error;
@@ -1527,9 +1526,7 @@ export class AgentBase {
1527
1526
  return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1528
1527
  if (queue.length === 0)
1529
1528
  return false;
1530
- // The durable queue, not memory, decides what is left to consume. Another owner over
1531
- // the same store may have taken these entries already, and a message answered twice
1532
- // is as wrong as one answered never.
1529
+ // The durable queue, not memory, decides what is left to consume after a restart.
1533
1530
  const durable = new Set((await this.#persistence.readValues(lockCtx, prefix)).map(({ key }) => key));
1534
1531
  const remaining = queue.filter((entry) => durable.has(entry.key));
1535
1532
  if (remaining.length !== queue.length)
@@ -1595,127 +1592,97 @@ export class AgentBase {
1595
1592
  reset = model !== this.#model;
1596
1593
  }
1597
1594
  }
1598
- const consumed = [];
1599
- try {
1600
- await this.#recordTransaction(lockCtx, async (txCtx) => {
1601
- if (selectionChanged) {
1602
- if (this.#hooks.modelChanged !== undefined && model !== undefined) {
1603
- // The hook runs while the persistence lock is held and inside the
1604
- // transaction that commits the switch, so its store executes directly on
1605
- // that transaction: what it writes lands and rolls back with the change
1606
- // it was told about, never on its own. The context it is given ends with
1607
- // the transaction, so a store it keeps cannot outlive the switch.
1608
- const committed = new AbortController();
1609
- // Derived from the transaction's own context, which is what makes
1610
- // the hook's writes part of the switch rather than a second,
1611
- // separate commit, and ending with it.
1612
- const changeLifetime = withLifetime(withAgentContext(txCtx, {
1613
- id: this.id,
1614
- provider,
1595
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
1596
+ if (selectionChanged) {
1597
+ if (this.#hooks.modelChanged !== undefined && model !== undefined) {
1598
+ // The hook runs while the persistence lock is held and inside the
1599
+ // transaction that commits the switch, so its store executes directly on
1600
+ // that transaction: what it writes lands and rolls back with the change
1601
+ // it was told about, never on its own. The context it is given ends with
1602
+ // the transaction, so a store it keeps cannot outlive the switch.
1603
+ const committed = new AbortController();
1604
+ // Derived from the transaction's own context, which is what makes
1605
+ // the hook's writes part of the switch rather than a second,
1606
+ // separate commit, and ending with it.
1607
+ const changeLifetime = withLifetime(withAgentContext(txCtx, {
1608
+ id: this.id,
1609
+ provider,
1610
+ model,
1611
+ effort,
1612
+ serviceTier,
1613
+ }), committed.signal);
1614
+ const changeCtx = withAgentRunKV(withAgentKV(changeLifetime, this.#kv), this.#runKV);
1615
+ try {
1616
+ injected = await this.#hooks.modelChanged(changeCtx, {
1617
+ previousModel: this.#model,
1615
1618
  model,
1616
- effort,
1617
- serviceTier,
1618
- }), committed.signal);
1619
- const changeCtx = withAgentRunKV(withAgentKV(changeLifetime, this.#kv), this.#runKV);
1620
- try {
1621
- injected = await this.#hooks.modelChanged(changeCtx, {
1622
- previousModel: this.#model,
1623
- model,
1624
- previousProvider: this.#providerId,
1625
- provider,
1626
- providers: this.#providers,
1627
- previousProviderInstance: this.#providers.get(this.#providerId),
1628
- providerInstance: this.#providers.get(provider),
1629
- wasReset: reset,
1630
- });
1631
- }
1632
- catch {
1633
- // A failing handoff must not cost the conversation: an incompatible
1634
- // switch is rejected outright — the previous selection stays
1635
- // effective and the history is not cleared. A compatible change
1636
- // proceeds; the hook only observed it.
1637
- if (reset) {
1638
- provider = this.#providerId;
1639
- model = this.#model;
1640
- reset = false;
1641
- }
1642
- }
1643
- finally {
1644
- // The store belonged to the hook's call, not to the hook.
1645
- committed.abort();
1619
+ previousProvider: this.#providerId,
1620
+ provider,
1621
+ providers: this.#providers,
1622
+ wasReset: reset,
1623
+ });
1624
+ }
1625
+ catch {
1626
+ // A failing handoff must not cost the conversation: an incompatible
1627
+ // switch is rejected outright — the previous selection stays
1628
+ // effective and the history is not cleared. A compatible change
1629
+ // proceeds; the hook only observed it.
1630
+ if (reset) {
1631
+ provider = this.#providerId;
1632
+ model = this.#model;
1633
+ reset = false;
1646
1634
  }
1647
- if (!reset)
1648
- injected = undefined;
1649
1635
  }
1650
- }
1651
- consumed.length = 0;
1652
- // Each entry is claimed as it is consumed: the delete answers whether this
1653
- // owner is the one that took it, so a message shared by two live owners over
1654
- // one store is answered exactly once. Claiming first also means losing the
1655
- // whole batch rolls the transaction back before it has changed anything.
1656
- for (const entry of batch) {
1657
- const claimed = await this.#persistence.deleteValueIfPresent(txCtx, entry.key);
1658
- if (claimed)
1659
- consumed.push(entry);
1660
- }
1661
- if (consumed.length === 0)
1662
- throw LOST_QUEUE_RACE;
1663
- if (reset) {
1664
- await this.#persistence.clearRecords(txCtx);
1665
- this.#loadedRecordCount = 0;
1666
- // The erased conversation is what the measurement described.
1667
- await this.#persistence.deleteValue(txCtx, "context");
1668
- if (injected !== undefined) {
1669
- await this.#appendRecord(txCtx, {
1670
- type: "system",
1671
- message: injected,
1672
- });
1636
+ finally {
1637
+ // The store belonged to the hook's call, not to the hook.
1638
+ committed.abort();
1673
1639
  }
1640
+ if (!reset)
1641
+ injected = undefined;
1674
1642
  }
1675
- for (const entry of consumed) {
1643
+ }
1644
+ // The queue move is atomic with appending the consumed messages and recording
1645
+ // the inference they make due. The store has one owner, so ordinary deletes
1646
+ // are sufficient.
1647
+ for (const entry of batch) {
1648
+ await this.#persistence.deleteValue(txCtx, entry.key);
1649
+ }
1650
+ if (reset) {
1651
+ await this.#persistence.clearRecords(txCtx);
1652
+ // The erased conversation is what the measurement described.
1653
+ await this.#persistence.deleteValue(txCtx, "context");
1654
+ if (injected !== undefined) {
1676
1655
  await this.#appendRecord(txCtx, {
1677
- type: "user",
1678
- message: entry.message,
1679
- });
1680
- }
1681
- if (changed) {
1682
- await this.#persistence.writeValue(txCtx, "settings", {
1683
- provider,
1684
- ...(model === undefined ? {} : { model }),
1685
- ...(effort === undefined ? {} : { effort }),
1686
- ...(serviceTier === undefined ? {} : { serviceTier }),
1656
+ type: "system",
1657
+ message: injected,
1687
1658
  });
1688
1659
  }
1689
- // Consuming a message is precisely the act that makes an inference owed, so
1690
- // the two commit as one. A crash cannot land between them and leave a
1691
- // message in the conversation that nothing remembers having to answer.
1692
- await this.#recordPending(txCtx, { stage: "inference" });
1693
- });
1694
- }
1695
- catch (error) {
1696
- if (error !== LOST_QUEUE_RACE)
1697
- throw error;
1698
- // Another owner answered all of them. They are gone from the store, so they are
1699
- // dropped from memory too, and this turn simply has nothing to inject.
1700
- queue.splice(0, count);
1701
- return false;
1702
- }
1660
+ }
1661
+ for (const entry of batch) {
1662
+ await this.#appendRecord(txCtx, {
1663
+ type: "user",
1664
+ message: entry.message,
1665
+ });
1666
+ }
1667
+ if (changed) {
1668
+ await this.#persistence.writeValue(txCtx, "settings", {
1669
+ provider,
1670
+ ...(model === undefined ? {} : { model }),
1671
+ ...(effort === undefined ? {} : { effort }),
1672
+ ...(serviceTier === undefined ? {} : { serviceTier }),
1673
+ });
1674
+ }
1675
+ // Consuming a message is precisely the act that makes an inference owed, so
1676
+ // the two commit as one. A crash cannot land between them and leave a
1677
+ // message in the conversation that nothing remembers having to answer.
1678
+ await this.#recordPending(txCtx, { stage: "inference" });
1679
+ });
1703
1680
  queue.splice(0, count);
1704
1681
  if (reset) {
1705
1682
  this.#messages = injected === undefined ? [] : [injected];
1706
1683
  this.#contextTokens = undefined;
1707
1684
  }
1708
- if (reset || provider !== this.#providerId) {
1709
- const session = this.#session;
1710
- this.#session = undefined;
1711
- try {
1712
- await session?.destroy();
1713
- }
1714
- catch {
1715
- // The change already committed; a failing destroy must not undo it.
1716
- }
1717
- }
1718
- this.#messages.push(...consumed.map((entry) => entry.message));
1685
+ this.#messages.push(...batch.map((entry) => entry.message));
1719
1686
  // This turn is answering the request that these messages raised. A send accepted
1720
1687
  // while the turn was already running raised it again, and letting that stand would
1721
1688
  // buy an extra turn with an empty queue and a full set of lifecycle hooks.
@@ -1745,9 +1712,6 @@ export class AgentBase {
1745
1712
  const records = await this.#persistence.load(lockCtx);
1746
1713
  const last = records[records.length - 1];
1747
1714
  this.#lastRecordType = last?.type;
1748
- this.#lastRecordContinuesInference =
1749
- last?.type === "compaction" && last.continuesInference === true;
1750
- this.#loadedRecordCount = records.length;
1751
1715
  let restored = messagesFromRecords(records);
1752
1716
  const steering = await this.#persistence.readValues(lockCtx, "steering.");
1753
1717
  const sends = await this.#persistence.readValues(lockCtx, "send.");
@@ -1877,7 +1841,8 @@ export class AgentBase {
1877
1841
  outcome = toolFailure(entry.call.callId, "The tool call was interrupted by a restart and was not retried.");
1878
1842
  }
1879
1843
  else {
1880
- const execution = this.#executeToolCall(withLifetime(this.#ctx, signal), entry.call);
1844
+ const toolLifetime = AbortSignal.any([signal, this.#closeController.signal]);
1845
+ const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry.call);
1881
1846
  running.push(execution);
1882
1847
  outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
1883
1848
  }
@@ -2007,20 +1972,15 @@ export class AgentBase {
2007
1972
  }
2008
1973
  }
2009
1974
  /**
2010
- * A key that sorts after every entry the queue already holds and belongs to no other
2011
- * writer. The order comes from the store rather than from a counter this instance keeps,
2012
- * because a restarted agent starts counting again and would otherwise reuse a key. The
2013
- * trailing writer segment settles the rest: two owners that read the same tail at the same
2014
- * millisecond still produce different keys, so an acknowledged message can never be
2015
- * overwritten by one accepted elsewhere — only ordered arbitrarily against it, which is all
2016
- * that simultaneous acceptance can mean. Reading the tail also keeps the order right when
2017
- * the clock goes backwards.
1975
+ * A key that sorts after every entry the queue already holds. The order comes from the store
1976
+ * rather than from an in-memory counter, because a restarted agent begins counting again.
1977
+ * Reading the tail also keeps order correct when the clock moves backwards.
2018
1978
  */
2019
1979
  async #queueKey(ctx, prefix) {
2020
1980
  const existing = await this.#persistence.readValues(ctx, prefix);
2021
1981
  const last = existing[existing.length - 1]?.key;
2022
1982
  const time = String(Date.now()).padStart(14, "0");
2023
- const key = (slot, sequence) => `${prefix}${slot}.${String(sequence).padStart(6, "0")}.${this.#writer}`;
1983
+ const key = (slot, sequence) => `${prefix}${slot}.${String(sequence).padStart(6, "0")}`;
2024
1984
  if (last === undefined)
2025
1985
  return key(time, 0);
2026
1986
  const [lastSlot, lastSequence] = last.slice(prefix.length).split(".");
@@ -2042,11 +2002,21 @@ export class AgentBase {
2042
2002
  // in-memory assistant message never diverges from what a reload would rebuild.
2043
2003
  const persisted = [];
2044
2004
  const toolCallIndexes = new Map();
2045
- const persist = async (block) => {
2046
- if (block === undefined)
2005
+ const persist = async (event) => {
2006
+ if (event === undefined)
2047
2007
  return;
2048
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#appendRecord(lockCtx, { type: "block", block }));
2049
- persisted.push(block);
2008
+ await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
2009
+ if (this.#hooks.onEventTransact === undefined) {
2010
+ await this.#appendRecord(lockCtx, { type: "block", block: event.block });
2011
+ }
2012
+ else {
2013
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
2014
+ await this.#appendRecord(txCtx, { type: "block", block: event.block });
2015
+ await this.#withTransactionalContext(txCtx, (hookCtx) => this.#hooks.onEventTransact?.(hookCtx, event));
2016
+ });
2017
+ }
2018
+ });
2019
+ persisted.push(event.block);
2050
2020
  };
2051
2021
  const iterator = stream[Symbol.asyncIterator]();
2052
2022
  // A response usually ends before its stream does — at the done event, or at an abort —
@@ -2085,7 +2055,7 @@ export class AgentBase {
2085
2055
  }
2086
2056
  case "text_end": {
2087
2057
  const last = content[content.length - 1];
2088
- await persist(last?.type === "text" ? last : undefined);
2058
+ await persist(last?.type === "text" ? { ...event, block: last } : undefined);
2089
2059
  break;
2090
2060
  }
2091
2061
  case "reasoning_start":
@@ -2111,7 +2081,7 @@ export class AgentBase {
2111
2081
  : { reasoning: event.reasoning }),
2112
2082
  };
2113
2083
  content[content.length - 1] = finished;
2114
- await persist(finished);
2084
+ await persist({ ...event, block: finished });
2115
2085
  }
2116
2086
  break;
2117
2087
  }
@@ -2141,7 +2111,7 @@ export class AgentBase {
2141
2111
  : { incomplete: event.incomplete }),
2142
2112
  };
2143
2113
  content[index] = finished;
2144
- await persist(finished);
2114
+ await persist({ ...event, block: finished });
2145
2115
  }
2146
2116
  break;
2147
2117
  }
@@ -2191,7 +2161,7 @@ export class AgentBase {
2191
2161
  * that moment; an unregistered ID fails the turn like any thrown error.
2192
2162
  */
2193
2163
  async #ensureSession(instructions, tools) {
2194
- const key = sessionConfigKey(instructions, tools);
2164
+ const key = sessionConfigKey(this.#providerId, this.#model, instructions, tools);
2195
2165
  if (this.#session !== undefined && this.#sessionConfig !== key) {
2196
2166
  const session = this.#session;
2197
2167
  this.#session = undefined;
@@ -2206,7 +2176,7 @@ export class AgentBase {
2206
2176
  }
2207
2177
  }
2208
2178
  if (this.#session === undefined) {
2209
- const provider = this.#providers.get(this.#providerId);
2179
+ const provider = await this.#providers.resolve(this.#providerId, this.#model);
2210
2180
  if (provider === null) {
2211
2181
  throw new Error(`Provider "${this.#providerId}" is not registered.`);
2212
2182
  }
@@ -2228,11 +2198,6 @@ export class AgentBase {
2228
2198
  }
2229
2199
  }
2230
2200
  }
2231
- /** Whether a conversation ends on something the model has not answered. */
2232
- function needsInference(messages) {
2233
- const last = messages[messages.length - 1];
2234
- return last?.role === "user" || last?.role === "tool" || last?.role === "system";
2235
- }
2236
2201
  /**
2237
2202
  * The conversation a run of records spells out. A compaction record carries the complete
2238
2203
  * replacement context and supersedes everything before it; consecutive blocks belong to one
@@ -2277,8 +2242,10 @@ function toolFailure(callId, reason) {
2277
2242
  * sees participate, so re-created tool objects with identical descriptors do not churn the
2278
2243
  * session.
2279
2244
  */
2280
- function sessionConfigKey(instructions, tools) {
2245
+ function sessionConfigKey(provider, model, instructions, tools) {
2281
2246
  return deterministicStringify([
2247
+ provider,
2248
+ model ?? null,
2282
2249
  instructions,
2283
2250
  tools.map((tool) => [
2284
2251
  tool.name,