@slopus/happy-agent-base 0.0.6 → 0.0.7

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 (90) hide show
  1. package/README.md +49 -11
  2. package/dist/Agent.d.ts +24 -22
  3. package/dist/Agent.d.ts.map +1 -1
  4. package/dist/Agent.js +113 -111
  5. package/dist/Agent.js.map +1 -1
  6. package/dist/AgentBase.d.ts +18 -14
  7. package/dist/AgentBase.d.ts.map +1 -1
  8. package/dist/AgentBase.js +476 -122
  9. package/dist/AgentBase.js.map +1 -1
  10. package/dist/AgentBaseHooks.d.ts +41 -28
  11. package/dist/AgentBaseHooks.d.ts.map +1 -1
  12. package/dist/AgentBasePending.d.ts +6 -2
  13. package/dist/AgentBasePending.d.ts.map +1 -1
  14. package/dist/AgentBasePending.js +7 -0
  15. package/dist/AgentBasePending.js.map +1 -1
  16. package/dist/AgentConfig.d.ts +9 -9
  17. package/dist/AgentConfig.d.ts.map +1 -1
  18. package/dist/AgentConfig.js +8 -8
  19. package/dist/AgentConfig.js.map +1 -1
  20. package/dist/AgentContexts.d.ts +18 -1
  21. package/dist/AgentContexts.d.ts.map +1 -1
  22. package/dist/AgentContexts.js +24 -1
  23. package/dist/AgentContexts.js.map +1 -1
  24. package/dist/AgentDatabase.d.ts +35 -0
  25. package/dist/AgentDatabase.d.ts.map +1 -0
  26. package/dist/AgentDatabase.js +30 -0
  27. package/dist/AgentDatabase.js.map +1 -0
  28. package/dist/AgentKV.d.ts +19 -6
  29. package/dist/AgentKV.d.ts.map +1 -1
  30. package/dist/AgentKV.js +110 -43
  31. package/dist/AgentKV.js.map +1 -1
  32. package/dist/AgentMessageAcceptance.d.ts +14 -0
  33. package/dist/AgentMessageAcceptance.d.ts.map +1 -0
  34. package/dist/AgentMessageAcceptance.js +2 -0
  35. package/dist/AgentMessageAcceptance.js.map +1 -0
  36. package/dist/AgentMetadata.d.ts +3 -3
  37. package/dist/AgentMetadata.js +3 -3
  38. package/dist/AgentModule.d.ts +209 -0
  39. package/dist/AgentModule.d.ts.map +1 -0
  40. package/dist/AgentModule.js +2 -0
  41. package/dist/AgentModule.js.map +1 -0
  42. package/dist/{AgentFeatureAction.d.ts → AgentModuleAction.d.ts} +2 -2
  43. package/dist/AgentModuleAction.d.ts.map +1 -0
  44. package/dist/AgentModuleAction.js +2 -0
  45. package/dist/AgentModuleAction.js.map +1 -0
  46. package/dist/AgentPermissionMode.d.ts +1 -1
  47. package/dist/AgentPermissionMode.js +1 -1
  48. package/dist/AgentPersistence.d.ts +7 -4
  49. package/dist/AgentPersistence.d.ts.map +1 -1
  50. package/dist/AgentPersistenceDrizzle.d.ts +31 -0
  51. package/dist/AgentPersistenceDrizzle.d.ts.map +1 -0
  52. package/dist/AgentPersistenceDrizzle.js +99 -0
  53. package/dist/AgentPersistenceDrizzle.js.map +1 -0
  54. package/dist/AgentRef.d.ts +12 -9
  55. package/dist/AgentRef.d.ts.map +1 -1
  56. package/dist/AgentRef.js +8 -8
  57. package/dist/AgentRef.js.map +1 -1
  58. package/dist/AgentStorage.d.ts +33 -13
  59. package/dist/AgentStorage.d.ts.map +1 -1
  60. package/dist/AgentStorage.js +174 -3
  61. package/dist/AgentStorage.js.map +1 -1
  62. package/dist/AgentSystem.d.ts +10 -7
  63. package/dist/AgentSystem.d.ts.map +1 -1
  64. package/dist/AgentSystemContext.d.ts +3 -3
  65. package/dist/AgentSystemContext.d.ts.map +1 -1
  66. package/dist/AgentSystemContext.js +4 -4
  67. package/dist/AgentSystemContext.js.map +1 -1
  68. package/dist/AgentSystemLocal.d.ts +17 -14
  69. package/dist/AgentSystemLocal.d.ts.map +1 -1
  70. package/dist/AgentSystemLocal.js +120 -29
  71. package/dist/AgentSystemLocal.js.map +1 -1
  72. package/dist/AgentSystemRef.d.ts +9 -7
  73. package/dist/AgentSystemRef.d.ts.map +1 -1
  74. package/dist/AgentSystemRef.js +17 -4
  75. package/dist/AgentSystemRef.js.map +1 -1
  76. package/dist/AgentTool.d.ts +16 -1
  77. package/dist/AgentTool.d.ts.map +1 -1
  78. package/dist/AgentTool.js.map +1 -1
  79. package/dist/index.d.ts +8 -6
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +7 -5
  82. package/dist/index.js.map +1 -1
  83. package/package.json +4 -3
  84. package/dist/AgentFeature.d.ts +0 -184
  85. package/dist/AgentFeature.d.ts.map +0 -1
  86. package/dist/AgentFeature.js +0 -2
  87. package/dist/AgentFeature.js.map +0 -1
  88. package/dist/AgentFeatureAction.d.ts.map +0 -1
  89. package/dist/AgentFeatureAction.js +0 -2
  90. package/dist/AgentFeatureAction.js.map +0 -1
package/dist/AgentBase.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { areProviderModelsCompatible } from "@slopus/happy-providers";
2
2
  import { createId } from "@paralleldrive/cuid2";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
+ import { Type } from "@sinclair/typebox";
4
5
  import { Value } from "@sinclair/typebox/value";
5
- import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
6
- import { withAgentContext, withAgentKV, withAgentPermissionMode, withAgentRunKV, } from "./AgentContexts.js";
6
+ import { afterCommit, asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
7
+ import { agentDatabase, agentKV, agentStorageTransaction, withAgentContext, withAgentDatabase, withAgentKV, withAgentPermissionMode, withAgentRunKV, withoutAgentStorageTransaction, } from "./AgentContexts.js";
7
8
  import { agentConfig, ownAgentConfig, withAgentConfig } from "./AgentConfig.js";
8
9
  import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
9
10
  import { AgentKV } from "./AgentKV.js";
@@ -36,6 +37,27 @@ const insidePersistenceLocks = new AsyncLocalStorage();
36
37
  * hears the shutdown finish, short enough that one still holding the loop is told promptly.
37
38
  */
38
39
  const INSIDE_CLOSE_REPORT_MS = 15;
40
+ const storedToolCallSchema = Type.Object({
41
+ id: cuid2Schema,
42
+ providerCallId: Type.String(),
43
+ call: Type.Object({
44
+ type: Type.Literal("tool_call"),
45
+ name: Type.String(),
46
+ arguments: Type.String(),
47
+ namespace: Type.Optional(Type.String()),
48
+ incomplete: Type.Optional(Type.Boolean()),
49
+ vendor: Type.Optional(Type.Unknown()),
50
+ }),
51
+ committed: Type.Optional(Type.Unknown()),
52
+ });
53
+ /** A durable first-writer-wins claim on the result one call will append. */
54
+ const storedToolResultSchema = Type.Object({
55
+ role: Type.Literal("tool"),
56
+ callId: Type.String(),
57
+ content: Type.Array(Type.Unknown()),
58
+ isError: Type.Optional(Type.Boolean()),
59
+ vendor: Type.Optional(Type.Unknown()),
60
+ });
39
61
  /**
40
62
  * A single agent session over one provider. Messages arrive through two FIFO queues: steering
41
63
  * messages inject as soon as the current assistant response and its tool batch finish, while
@@ -119,7 +141,7 @@ const INSIDE_CLOSE_REPORT_MS = 15;
119
141
  * running under rather than being told.
120
142
  *
121
143
  * The loop enforces nothing. It has no idea what any particular tool touches, and a runtime that
122
- * guessed would be wrong about tools it has never seen. Enforcement belongs to the features and
144
+ * guessed would be wrong about tools it has never seen. Enforcement belongs to the modules and
123
145
  * tools that do know; the loop's whole part is to carry the mode, make its changes durable, and
124
146
  * report them.
125
147
  *
@@ -289,6 +311,13 @@ export class AgentBase {
289
311
  * about the agent is answered from here without touching the disk.
290
312
  */
291
313
  #pending;
314
+ /** Stable Base-owned lifecycle identities persisted as part of the pending run. */
315
+ #loopId;
316
+ #turnId;
317
+ #inferenceId;
318
+ #settlementId;
319
+ /** The durable settlement awaiting its post-commit observing hook. */
320
+ #settlement;
292
321
  /**
293
322
  * The pending state this instance last wrote, so a write that would change nothing is
294
323
  * skipped. The loop passes through the same stage many times in a turn, and a store is not
@@ -326,6 +355,8 @@ export class AgentBase {
326
355
  #contextTokens;
327
356
  /** Whether the current turn was cancelled before it could finish. */
328
357
  #turnAborted = false;
358
+ /** A staged tool result could not settle, so this run must leave its pending state intact. */
359
+ #durableWorkBlocked = false;
329
360
  /** Whether something has asked for a turn that has not been answered yet. */
330
361
  #turnRequested = false;
331
362
  /** The run loop while it is running; the field is cleared once it has actually stopped. */
@@ -350,6 +381,8 @@ export class AgentBase {
350
381
  * owners of one session at once.
351
382
  */
352
383
  #streamCleanup = new Set();
384
+ /** IDs offered by this process, for immediate self-reentrant acceptance answers. */
385
+ #offeredMessageIds = new Set();
353
386
  /**
354
387
  * A new agent, wired to its options and touching no storage at all. Use this for an identity
355
388
  * with no durable state yet; whatever the agent needs from the store is read by its first
@@ -401,6 +434,10 @@ export class AgentBase {
401
434
  this.#inherited = stored;
402
435
  this.#inheritedRead = true;
403
436
  this.#pending = stored;
437
+ this.#loopId = stored?.loopId;
438
+ this.#turnId = stored?.turnId;
439
+ this.#inferenceId = stored?.inferenceId;
440
+ this.#settlementId = stored?.settlementId;
404
441
  this.#pendingWritten =
405
442
  stored === undefined ? undefined : deterministicStringify(stored);
406
443
  });
@@ -417,7 +454,7 @@ export class AgentBase {
417
454
  // An agent is its own lifetime. Whatever call happened to construct it — a tool of
418
455
  // another agent, most often — is not a loop this one runs inside, so an inherited
419
456
  // marker is dropped rather than carried into work that outlives that call.
420
- this.#baseCtx = withAgentConfig(insideTurn.set(ctx, [options.id]), this.#config);
457
+ this.#baseCtx = withoutAgentStorageTransaction(withAgentDatabase(withAgentConfig(insideTurn.set(ctx, [options.id]), this.#config), options.persistence.database));
421
458
  this.#providers = options.providers;
422
459
  this.#providerId = options.provider;
423
460
  this.#persistence = options.persistence;
@@ -450,7 +487,8 @@ export class AgentBase {
450
487
  /** Add this agent's selection and stores to a caller context without losing its transaction. */
451
488
  #hookContext(ctx) {
452
489
  const selected = withAgentContext(ctx, this.#selection());
453
- return withAgentRunKV(withAgentKV(selected, this.#kv), this.#runKV);
490
+ const database = agentDatabase(ctx) ?? this.#persistence.database;
491
+ return withAgentRunKV(withAgentKV(withAgentDatabase(selected, database), this.#kv), this.#runKV);
454
492
  }
455
493
  /** Load a directly owned configuration written by `updateMetadata`, when one exists. */
456
494
  async #loadConfig(ctx) {
@@ -489,7 +527,8 @@ export class AgentBase {
489
527
  * whatever else that transaction is writing, which is how a consumed message and the
490
528
  * inference it owes become durable as one fact rather than two.
491
529
  */
492
- async #recordPending(ctx, pending, force = false) {
530
+ async #recordPending(ctx, stage, force = false) {
531
+ const pending = this.#pendingState(stage);
493
532
  const serialized = deterministicStringify(pending);
494
533
  if (!force && this.#pendingWritten === serialized)
495
534
  return;
@@ -497,6 +536,17 @@ export class AgentBase {
497
536
  this.#pending = pending;
498
537
  this.#pendingWritten = serialized;
499
538
  }
539
+ /** The complete durable lifecycle identity state for one outstanding stage. */
540
+ #pendingState(stage) {
541
+ this.#loopId ??= createId();
542
+ return {
543
+ stage,
544
+ loopId: this.#loopId,
545
+ ...(this.#turnId === undefined ? {} : { turnId: this.#turnId }),
546
+ ...(this.#inferenceId === undefined ? {} : { inferenceId: this.#inferenceId }),
547
+ ...(this.#settlementId === undefined ? {} : { settlementId: this.#settlementId }),
548
+ };
549
+ }
500
550
  /**
501
551
  * Record the stage the run has reached, taking the store lock when not already inside it.
502
552
  *
@@ -506,7 +556,7 @@ export class AgentBase {
506
556
  * starting here.
507
557
  */
508
558
  async #enterStage(stage, transact) {
509
- const pending = { stage };
559
+ const pending = this.#pendingState(stage);
510
560
  if (transact === undefined &&
511
561
  deterministicStringify(pending) === this.#pendingWritten &&
512
562
  this.#inheritedRead) {
@@ -519,14 +569,14 @@ export class AgentBase {
519
569
  this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
520
570
  }
521
571
  if (transact === undefined) {
522
- await this.#recordPending(lockCtx, pending);
572
+ await this.#recordPending(lockCtx, stage);
523
573
  return undefined;
524
574
  }
525
575
  return await this.#recordTransaction(lockCtx, async (txCtx) => {
526
576
  // The state is staged first. The callback then writes against this exact
527
577
  // transaction, so neither its conclusion nor the state it observed can land
528
578
  // without the other.
529
- await this.#recordPending(txCtx, pending, true);
579
+ await this.#recordPending(txCtx, stage, true);
530
580
  return await this.#withTransactionalContext(txCtx, transact);
531
581
  });
532
582
  });
@@ -552,28 +602,29 @@ export class AgentBase {
552
602
  /**
553
603
  * Queue a user message that injects as soon as the current assistant response and its tool
554
604
  * batch finish; steering always takes precedence over sent messages. Returns once the message
555
- * has been handed to the agent, which never waits for the turn that answers it; with
556
- * `await: true` it returns once the durable write has landed instead, and a failed write both
557
- * rejects and keeps the message out of the conversation entirely.
605
+ * has been handed to the agent and returns its acceptance identity. Outside the target's own
606
+ * loop it waits for the durable created/existing result by default; a failed write rejects and
607
+ * keeps the message out of the conversation entirely.
558
608
  */
559
609
  async steer(ctx, message, options) {
560
- await this.#offer(ctx, "steering", message, options);
610
+ return await this.#offer(ctx, "steering", message, options);
561
611
  }
562
612
  /**
563
613
  * Queue a user message that waits until the agent would otherwise stop — no tool calls or
564
614
  * steering remain — before injecting. Returns once the message has been handed to the agent,
565
- * which never waits for the turn that answers it; with `await: true` it returns once the
566
- * durable write has landed instead, and a failed write both rejects and keeps the message out
567
- * of the conversation entirely.
615
+ * without waiting for the turn that answers it and returns its acceptance identity. Outside
616
+ * the target's own loop it waits for the durable created/existing result by default; a failed
617
+ * write rejects and keeps the message out of the conversation entirely.
568
618
  */
569
619
  async send(ctx, message, options) {
570
- await this.#offer(ctx, "send", message, options);
620
+ return await this.#offer(ctx, "send", message, options);
571
621
  }
572
622
  /**
573
623
  * Shallow-merge fields into this agent's immutable metadata. The complete AgentConfig and
574
624
  * transactional hook writes commit together; observing hooks run only after that commit.
575
625
  */
576
626
  async updateMetadata(ctx, update) {
627
+ this.#assertOutsideStorageTransaction(ctx, "metadata update");
577
628
  const ownedUpdate = ownAgentMetadata(update);
578
629
  if (ownedUpdate === undefined)
579
630
  throw new Error("The agent metadata is not valid.");
@@ -616,16 +667,20 @@ export class AgentBase {
616
667
  * the agent's own close still drains it, so nothing is dropped by not looking.
617
668
  */
618
669
  async #offer(ctx, kind, message, options) {
619
- const { await: wait = false, id = createId(), metadata: suppliedMetadata, ...settings } = options ?? {};
670
+ this.#assertOutsideStorageTransaction(ctx, "message delivery");
671
+ const { await: requestedWait, id = createId(), metadata: suppliedMetadata, ...settings } = options ?? {};
620
672
  if (!Value.Check(cuid2Schema, id)) {
621
673
  throw new Error("The message ID must be a cuid2 identity.");
622
674
  }
675
+ const wait = requestedWait ?? !insideTurn.get(ctx).includes(this.id);
623
676
  const metadata = ownAgentMessageMetadata(suppliedMetadata);
624
677
  // Refusing the flag rather than the operation: a closed agent and a re-entrant wait are
625
678
  // both caller mistakes, and both are reported before any work is started.
626
679
  this.#assertCanWait(ctx, wait, kind === "steering" ? "a steered message" : "a sent message");
627
680
  if (this.#closed)
628
681
  throw new Error("The agent has been closed.");
682
+ const knownInProcess = this.#offeredMessageIds.has(id);
683
+ this.#offeredMessageIds.add(id);
629
684
  const accepted = this.#enqueue(ctx, [
630
685
  {
631
686
  kind,
@@ -635,9 +690,29 @@ export class AgentBase {
635
690
  options: settings,
636
691
  },
637
692
  ]);
638
- if (wait)
639
- return accepted;
640
- accepted.catch(() => undefined);
693
+ if (wait) {
694
+ try {
695
+ const [result] = await accepted;
696
+ if (result === undefined) {
697
+ throw new Error("The message acceptance result was lost.");
698
+ }
699
+ return result;
700
+ }
701
+ catch (error) {
702
+ if (!knownInProcess)
703
+ this.#offeredMessageIds.delete(id);
704
+ throw error;
705
+ }
706
+ }
707
+ accepted.catch(() => {
708
+ if (!knownInProcess)
709
+ this.#offeredMessageIds.delete(id);
710
+ });
711
+ return {
712
+ id,
713
+ delivery: kind === "steering" ? "steer" : "send",
714
+ accepted: knownInProcess ? "existing" : "created",
715
+ };
641
716
  }
642
717
  /**
643
718
  * Refuse a wait that could never end. A hook or a tool runs while its agent's loop waits for
@@ -666,6 +741,12 @@ export class AgentBase {
666
741
  #insideOwnPersistenceLock() {
667
742
  return insidePersistenceLocks.getStore()?.includes(this.id) === true;
668
743
  }
744
+ /** Live agent commands cannot be staged or undone by an enclosing database transaction. */
745
+ #assertOutsideStorageTransaction(ctx, operation) {
746
+ if (agentStorageTransaction(ctx) !== undefined) {
747
+ throw new Error(`Agent ${operation} cannot run from inside an outer storage transaction.`);
748
+ }
749
+ }
669
750
  /** Hold the persistence lock while marking it independently of the caller's Context. */
670
751
  async #runInPersistenceLock(ctx, work) {
671
752
  return await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
@@ -683,17 +764,23 @@ export class AgentBase {
683
764
  */
684
765
  async #enqueue(ctx, batch) {
685
766
  if (batch.length === 0)
686
- return;
767
+ return [];
687
768
  if (this.#closed)
688
769
  throw new Error("The agent has been closed.");
689
770
  // Admitted: from here on the messages are the agent's responsibility, and a close that
690
771
  // begins now waits for them rather than resolving over the top of them.
691
772
  const admitted = this.#runInPersistenceLock(ctx, async (lockCtx) => {
692
773
  const accepted = [];
693
- await this.#persistence.transaction(lockCtx, async (txCtx) => {
774
+ const results = [];
775
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
694
776
  for (const request of batch) {
695
777
  const identityKey = `message.${request.id}`;
696
778
  if (!(await this.#persistence.writeValueIfAbsent(txCtx, identityKey, true))) {
779
+ results.push({
780
+ id: request.id,
781
+ delivery: request.kind === "steering" ? "steer" : "send",
782
+ accepted: "existing",
783
+ });
697
784
  continue;
698
785
  }
699
786
  const key = await this.#queueKey(txCtx, `${request.kind}.`);
@@ -704,13 +791,18 @@ export class AgentBase {
704
791
  options: request.options,
705
792
  });
706
793
  accepted.push({ key, request });
794
+ results.push({
795
+ id: request.id,
796
+ delivery: request.kind === "steering" ? "steer" : "send",
797
+ accepted: "created",
798
+ });
707
799
  }
708
800
  if (accepted.length === 0)
709
801
  return;
710
802
  // Accepting a message is what makes the work owed: the same transaction that
711
803
  // admits it records that the agent owes an answer, so a process that dies right
712
804
  // here is discovered still owing it rather than looking idle over a full queue.
713
- await this.#recordPending(txCtx, { stage: "inference" });
805
+ await this.#recordPending(txCtx, "inference");
714
806
  });
715
807
  for (const { key, request } of accepted) {
716
808
  // The queue is resolved inside the lock: a history load running just before this
@@ -725,14 +817,15 @@ export class AgentBase {
725
817
  options: request.options,
726
818
  });
727
819
  }
728
- if (accepted.length === 0)
729
- return;
730
- this.#turnRequested = true;
731
- this.#startRun();
820
+ if (accepted.length > 0) {
821
+ this.#turnRequested = true;
822
+ this.#startRun();
823
+ }
824
+ return results;
732
825
  });
733
826
  this.#admitted.add(admitted);
734
827
  try {
735
- await admitted;
828
+ return await admitted;
736
829
  }
737
830
  finally {
738
831
  this.#admitted.delete(admitted);
@@ -771,6 +864,7 @@ export class AgentBase {
771
864
  * compaction rather than queueing another.
772
865
  */
773
866
  async compact(ctx, options) {
867
+ this.#assertOutsideStorageTransaction(ctx, "compaction");
774
868
  const wait = options?.await ?? false;
775
869
  this.#assertCanWait(ctx, wait, "a compaction");
776
870
  if (this.#closed)
@@ -896,6 +990,7 @@ export class AgentBase {
896
990
  * when it finished.
897
991
  */
898
992
  async abort(ctx, options) {
993
+ this.#assertOutsideStorageTransaction(ctx, "abort");
899
994
  const wait = options?.await ?? false;
900
995
  this.#assertCanWait(ctx, wait, "an abort");
901
996
  const run = this.#signalAbort();
@@ -1038,9 +1133,20 @@ export class AgentBase {
1038
1133
  * more work, so the loop hooks always bracket a settled-to-settled span.
1039
1134
  */
1040
1135
  async #runLoop() {
1136
+ if (this.#pending?.stage === "settlement") {
1137
+ this.#loopId ??= createId();
1138
+ this.#settlementId ??= createId();
1139
+ await this.#settleDurably({
1140
+ loopId: this.#loopId,
1141
+ settlementId: this.#settlementId,
1142
+ });
1143
+ return;
1144
+ }
1041
1145
  // The outer loop reopens when an `afterAgentLoop` action requests more work, so the
1042
1146
  // loop hooks always bracket a settled-to-settled span.
1043
1147
  do {
1148
+ this.#loopId ??= createId();
1149
+ const loop = { loopId: this.#loopId };
1044
1150
  // The abort scope opens before the loop hook, not just before the turn. An abort
1045
1151
  // owns everything the run does — its opening hook as much as its inference — so a
1046
1152
  // run cancelled while it is still starting up never reaches the model at all.
@@ -1049,10 +1155,14 @@ export class AgentBase {
1049
1155
  // crash could interrupt. What it records is refined as the run reaches each stage;
1050
1156
  // what matters at this point is that the record exists at all, since its absence is
1051
1157
  // what a later process reads as an agent that finished.
1052
- await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact);
1053
- await this.#invokeHook(this.#hooks.beforeAgentLoop);
1158
+ await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact === undefined
1159
+ ? undefined
1160
+ : (hookCtx) => this.#hooks.beforeAgentLoopTransact?.(hookCtx, loop));
1161
+ await this.#invokeHook(this.#hooks.beforeAgentLoop, loop);
1054
1162
  do {
1055
1163
  this.#turnAborted = false;
1164
+ this.#durableWorkBlocked = false;
1165
+ this.#turnId ??= createId();
1056
1166
  // Claimed before any awaiting, so a request raised while the turn is still
1057
1167
  // starting up survives into another turn instead of being cleared by it. The
1058
1168
  // redundant turn this can cost is cheap: an empty queue drains without any
@@ -1076,9 +1186,12 @@ export class AgentBase {
1076
1186
  ? loadFailure.message
1077
1187
  : String(loadFailure),
1078
1188
  });
1189
+ this.#turnId = undefined;
1079
1190
  break;
1080
1191
  }
1081
1192
  const turnStart = {
1193
+ loopId: loop.loopId,
1194
+ turnId: this.#turnId,
1082
1195
  contextTokens: this.#contextTokens,
1083
1196
  };
1084
1197
  await this.#enterStage("inference", this.#hooks.beforeTurnTransact === undefined
@@ -1086,13 +1199,25 @@ export class AgentBase {
1086
1199
  : (hookCtx) => this.#hooks.beforeTurnTransact?.(hookCtx, turnStart));
1087
1200
  await this.#applyActions(this.#hooks.beforeTurn, abort.signal, turnStart);
1088
1201
  await this.#runInference(abort);
1202
+ if (this.#durableWorkBlocked)
1203
+ return;
1089
1204
  const turn = {
1205
+ loopId: loop.loopId,
1206
+ turnId: turnStart.turnId,
1090
1207
  contextTokens: this.#contextTokens,
1091
1208
  aborted: this.#turnAborted,
1092
1209
  };
1093
- await this.#enterStage("inference", this.#hooks.afterTurnTransact === undefined
1094
- ? undefined
1095
- : (hookCtx) => this.#hooks.afterTurnTransact?.(hookCtx, turn));
1210
+ const completedTurnId = this.#turnId;
1211
+ this.#turnId = undefined;
1212
+ try {
1213
+ await this.#enterStage("inference", this.#hooks.afterTurnTransact === undefined
1214
+ ? undefined
1215
+ : (hookCtx) => this.#hooks.afterTurnTransact?.(hookCtx, turn));
1216
+ }
1217
+ catch (error) {
1218
+ this.#turnId = completedTurnId;
1219
+ throw error;
1220
+ }
1096
1221
  await this.#applyActions(this.#hooks.afterTurn, abort.signal, turn);
1097
1222
  if (!this.#turnRequested || this.#closed)
1098
1223
  break;
@@ -1100,14 +1225,21 @@ export class AgentBase {
1100
1225
  // keeps the run's first turn under the scope its opening hook already ran in.
1101
1226
  abort = this.#openAbortScope();
1102
1227
  } while (true);
1103
- await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact);
1104
- await this.#applyActions(this.#hooks.afterAgentLoop, abort.signal);
1228
+ await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact === undefined
1229
+ ? undefined
1230
+ : (hookCtx) => this.#hooks.afterAgentLoopTransact?.(hookCtx, loop));
1231
+ await this.#applyActions(this.#hooks.afterAgentLoop, abort.signal, loop);
1105
1232
  } while (this.#turnRequested && !this.#closed);
1106
1233
  // Nothing is asked for any more, so the outstanding work is erased. That erasure is what
1107
1234
  // makes the agent idle, and it commits together with whatever the settling hooks write,
1108
1235
  // so no owner can ever see the agent finished without their conclusions or their
1109
1236
  // conclusions without the agent being finished.
1110
- await this.#settleDurably();
1237
+ this.#settlementId ??= createId();
1238
+ await this.#enterStage("settlement");
1239
+ await this.#settleDurably({
1240
+ loopId: this.#loopId ?? createId(),
1241
+ settlementId: this.#settlementId,
1242
+ });
1111
1243
  }
1112
1244
  /**
1113
1245
  * Erase the outstanding work and let the transactional settling hooks write in the same
@@ -1115,17 +1247,22 @@ export class AgentBase {
1115
1247
  * is resumed and finds nothing to do, while one wrongly believed to be finished is never
1116
1248
  * resumed at all.
1117
1249
  */
1118
- async #settleDurably() {
1250
+ async #settleDurably(settlement) {
1119
1251
  try {
1120
1252
  await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
1121
1253
  await this.#clearPending(txCtx);
1122
- await this.#invokeTransactionalSettle(txCtx);
1254
+ await this.#invokeTransactionalSettle(txCtx, settlement);
1123
1255
  // The run store is erased last, so a settling hook can still read what the
1124
1256
  // run concluded and keep whatever part of it belongs to the conversation.
1125
1257
  // It commits with the settlement: the run is over and its notes are gone as
1126
1258
  // one fact, never one without the other.
1127
1259
  await this.#clearRunStore(txCtx);
1128
1260
  }));
1261
+ this.#loopId = undefined;
1262
+ this.#turnId = undefined;
1263
+ this.#inferenceId = undefined;
1264
+ this.#settlementId = undefined;
1265
+ this.#settlement = settlement;
1129
1266
  }
1130
1267
  catch {
1131
1268
  // The run itself is over and succeeded; only the record of its ending failed.
@@ -1138,11 +1275,11 @@ export class AgentBase {
1138
1275
  * with it, because a hook here is writing a conclusion about the very fact being committed,
1139
1276
  * and half of that pair is worse than neither.
1140
1277
  */
1141
- async #invokeTransactionalSettle(txCtx) {
1278
+ async #invokeTransactionalSettle(txCtx, settlement) {
1142
1279
  const hook = this.#hooks.afterAgentSettledTransact;
1143
1280
  if (hook === undefined)
1144
1281
  return;
1145
- await this.#withTransactionalContext(insideTurn.set(txCtx, []), hook);
1282
+ await this.#withTransactionalContext(insideTurn.set(txCtx, []), (liveCtx) => hook(liveCtx, settlement));
1146
1283
  }
1147
1284
  /**
1148
1285
  * Erase everything the run wrote about itself, inside the transaction that settles the agent:
@@ -1175,7 +1312,11 @@ export class AgentBase {
1175
1312
  // The settle runs once the loop has stopped, so its hook is not inside a turn and
1176
1313
  // its context does not claim to be: a compaction it waits for reaches a loop that
1177
1314
  // can still be started.
1178
- await this.#invokeHookOn(insideTurn.set(this.#ctx, []), this.#hooks.afterAgentSettled);
1315
+ const settlement = this.#settlement;
1316
+ if (settlement !== undefined) {
1317
+ await this.#invokeHookOn(insideTurn.set(this.#ctx, []), this.#hooks.afterAgentSettled, settlement);
1318
+ this.#settlement = undefined;
1319
+ }
1179
1320
  })();
1180
1321
  this.#admitted.add(announced);
1181
1322
  void announced.finally(() => this.#admitted.delete(announced));
@@ -1194,7 +1335,7 @@ export class AgentBase {
1194
1335
  await this.#invokeHookOn(this.#ctx, hook, ...args);
1195
1336
  }
1196
1337
  /**
1197
- * Lend a hook the transaction's context and feature stores for exactly one callback. Keeping
1338
+ * Lend a hook the transaction's context and module stores for exactly one callback. Keeping
1198
1339
  * the context after the callback cannot leak a transaction past its commit.
1199
1340
  */
1200
1341
  async #withTransactionalContext(txCtx, work) {
@@ -1370,8 +1511,19 @@ export class AgentBase {
1370
1511
  // either.
1371
1512
  if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
1372
1513
  continue;
1373
- await this.#enterStage("inference", this.#hooks.beforeInferenceTransact);
1374
- await this.#invokeHook(this.#hooks.beforeInference);
1514
+ this.#inferenceId ??= createId();
1515
+ const inferenceStart = {
1516
+ loopId: this.#loopId ?? createId(),
1517
+ turnId: this.#turnId ?? createId(),
1518
+ inferenceId: this.#inferenceId,
1519
+ contextTokens: this.#contextTokens,
1520
+ };
1521
+ this.#loopId = inferenceStart.loopId;
1522
+ this.#turnId = inferenceStart.turnId;
1523
+ await this.#enterStage("inference", this.#hooks.beforeInferenceTransact === undefined
1524
+ ? undefined
1525
+ : (hookCtx) => this.#hooks.beforeInferenceTransact?.(hookCtx, inferenceStart));
1526
+ await this.#invokeHook(this.#hooks.beforeInference, inferenceStart);
1375
1527
  const stream = session.run(this.#ctx, {
1376
1528
  context: {
1377
1529
  instructions,
@@ -1385,6 +1537,7 @@ export class AgentBase {
1385
1537
  // A cancelled or failed response measures nothing, so the conversation keeps
1386
1538
  // the last real measurement instead of forgetting how large it had become.
1387
1539
  const inference = {
1540
+ ...inferenceStart,
1388
1541
  state,
1389
1542
  tokens,
1390
1543
  ...(errorMessage === undefined ? {} : { errorMessage }),
@@ -1392,11 +1545,19 @@ export class AgentBase {
1392
1545
  const afterInferenceTransact = this.#hooks.afterInferenceTransact === undefined
1393
1546
  ? undefined
1394
1547
  : (hookCtx) => this.#hooks.afterInferenceTransact?.(hookCtx, inference);
1395
- if (tokens === undefined) {
1396
- await this.#enterStage("inference", afterInferenceTransact);
1548
+ const completedInferenceId = this.#inferenceId;
1549
+ this.#inferenceId = undefined;
1550
+ try {
1551
+ if (tokens === undefined) {
1552
+ await this.#enterStage("inference", afterInferenceTransact);
1553
+ }
1554
+ else {
1555
+ await this.#recordContextTokens(tokens.input + tokens.output, afterInferenceTransact);
1556
+ }
1397
1557
  }
1398
- else {
1399
- await this.#recordContextTokens(tokens.input + tokens.output, afterInferenceTransact);
1558
+ catch (error) {
1559
+ this.#inferenceId = completedInferenceId;
1560
+ throw error;
1400
1561
  }
1401
1562
  await this.#invokeHook(this.#hooks.afterInference, inference);
1402
1563
  if (content.length > 0) {
@@ -1418,10 +1579,7 @@ export class AgentBase {
1418
1579
  const calls = content.filter((block) => block.type === "tool_call" && block.server !== true);
1419
1580
  if (calls.length === 0)
1420
1581
  continue;
1421
- const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => ({
1422
- key: this.#toolKey(index, call.callId),
1423
- call,
1424
- })), false, abort.signal, abortPromise);
1582
+ const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => this.#newToolEntry(index, call)), false, abort.signal, abortPromise);
1425
1583
  if (closedDuringTools)
1426
1584
  break;
1427
1585
  needsInference = true;
@@ -1505,6 +1663,7 @@ export class AgentBase {
1505
1663
  }
1506
1664
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1507
1665
  await write(txCtx);
1666
+ await this.#recordPending(txCtx, "inference", true);
1508
1667
  await this.#withTransactionalContext(txCtx, transact);
1509
1668
  });
1510
1669
  });
@@ -1589,32 +1748,55 @@ export class AgentBase {
1589
1748
  if (owed.length === 0)
1590
1749
  return true;
1591
1750
  let settled = false;
1751
+ let staged = false;
1592
1752
  try {
1593
1753
  await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1594
1754
  // A call the durable batch still holds belongs to the resume, which answers it
1595
1755
  // properly — and re-executes it when the tool is durable. Settling it here as
1596
1756
  // well would give the conversation two results for one call.
1597
1757
  const pending = await this.#persistence.readValues(lockCtx, "tool.");
1598
- const dispatched = new Set(pending.map(({ value }) => value.callId));
1599
- const results = owed
1600
- .filter((call) => !dispatched.has(call.callId))
1601
- .map((call) => toolFailure(call.callId, reason));
1602
- if (results.length === 0)
1758
+ const dispatched = new Set(pending.map(({ key, value }) => this.#restoreToolEntry(key, value).providerCallId));
1759
+ if (owed.some((call) => dispatched.has(call.callId))) {
1760
+ settled = true;
1603
1761
  return;
1762
+ }
1763
+ const entries = owed.map((call, index) => {
1764
+ const entry = this.#newToolEntry(index, call);
1765
+ return {
1766
+ ...entry,
1767
+ committed: toolFailure(entry.providerCallId, reason),
1768
+ };
1769
+ });
1770
+ // Give every otherwise-undispatched call a durable internal identity and staged
1771
+ // result first. If the settlement transaction below fails, restart resumes these
1772
+ // exact entries and commits their staged errors without executing the tools.
1604
1773
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1605
- for (const result of results) {
1774
+ for (const entry of entries) {
1775
+ await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry(entry));
1776
+ }
1777
+ await this.#recordPending(txCtx, "tools");
1778
+ });
1779
+ staged = true;
1780
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
1781
+ for (const entry of entries) {
1782
+ const result = entry.committed;
1606
1783
  await this.#appendRecord(txCtx, { type: "tool", message: result });
1784
+ await this.#persistence.deleteValue(txCtx, entry.key);
1607
1785
  // A result the conversation records is a result the hook sees, however
1608
1786
  // little of a run produced it. A hook that fails here leaves the calls
1609
1787
  // unsettled, which is what lets a later attempt answer them properly.
1610
- await this.#invokeToolTransactHook(txCtx, result.callId, this.#hooks.afterToolCallTransact, result);
1788
+ await this.#invokeToolTransactHook(txCtx, entry.id, entry.providerCallId, this.#hooks.afterToolCallTransact, result);
1789
+ await this.#kv.scoped("call", entry.id).clear(txCtx);
1611
1790
  }
1791
+ await this.#recordPending(txCtx, "inference");
1612
1792
  });
1613
- this.#messages.push(...results);
1793
+ this.#messages.push(...entries.map(({ committed }) => committed));
1794
+ settled = true;
1614
1795
  });
1615
- settled = true;
1616
1796
  }
1617
1797
  catch {
1798
+ if (staged)
1799
+ this.#durableWorkBlocked = true;
1618
1800
  // The turn is already failing; a restart settles what this could not, as long as
1619
1801
  // nothing is written over the top of the call in the meantime.
1620
1802
  }
@@ -1631,6 +1813,7 @@ export class AgentBase {
1631
1813
  for (const record of records) {
1632
1814
  if (record.type === "user") {
1633
1815
  await this.#persistence.deleteValue(ctx, `message.${record.id}`);
1816
+ this.#offeredMessageIds.delete(record.id);
1634
1817
  }
1635
1818
  }
1636
1819
  }
@@ -1643,12 +1826,20 @@ export class AgentBase {
1643
1826
  // agent skip the write that actually records what it is doing.
1644
1827
  const pending = this.#pending;
1645
1828
  const written = this.#pendingWritten;
1829
+ const loopId = this.#loopId;
1830
+ const turnId = this.#turnId;
1831
+ const inferenceId = this.#inferenceId;
1832
+ const settlementId = this.#settlementId;
1646
1833
  try {
1647
1834
  return await this.#persistence.transaction(ctx, work);
1648
1835
  }
1649
1836
  catch (error) {
1650
1837
  this.#pending = pending;
1651
1838
  this.#pendingWritten = written;
1839
+ this.#loopId = loopId;
1840
+ this.#turnId = turnId;
1841
+ this.#inferenceId = inferenceId;
1842
+ this.#settlementId = settlementId;
1652
1843
  throw error;
1653
1844
  }
1654
1845
  }
@@ -1734,7 +1925,7 @@ export class AgentBase {
1734
1925
  }
1735
1926
  }
1736
1927
  // The mode the messages make effective, kept apart from the rest because it is the one
1737
- // setting with hooks of its own: a change is announced, and what a feature concludes
1928
+ // setting with hooks of its own: a change is announced, and what a module concludes
1738
1929
  // from it commits with the message that carried it.
1739
1930
  const modeChange = permissionMode === this.#permissionMode
1740
1931
  ? undefined
@@ -1858,7 +2049,7 @@ export class AgentBase {
1858
2049
  // Consuming a message is precisely the act that makes an inference owed, so
1859
2050
  // the two commit as one. A crash cannot land between them and leave a
1860
2051
  // message in the conversation that nothing remembers having to answer.
1861
- await this.#recordPending(txCtx, { stage: "inference" });
2052
+ await this.#recordPending(txCtx, "inference");
1862
2053
  // Last, so a hook writing its own account of the consumption sees a transaction
1863
2054
  // holding all of it. The mode comes before the messages: it is what they were
1864
2055
  // said under, and a listener recording them wants to know that first.
@@ -1985,10 +2176,7 @@ export class AgentBase {
1985
2176
  }
1986
2177
  this.#ctx = this.#deriveCtx();
1987
2178
  }
1988
- this.#pendingTools = pendingTools.map(({ key, value }) => ({
1989
- key,
1990
- call: value,
1991
- }));
2179
+ this.#pendingTools = pendingTools.map(({ key, value }) => this.#restoreToolEntry(key, value));
1992
2180
  this.#pendingToolsUndispatched = false;
1993
2181
  if (this.#pendingTools.length === 0) {
1994
2182
  // A crash between the response's last block and the batch commit leaves calls
@@ -1998,10 +2186,7 @@ export class AgentBase {
1998
2186
  // dispatched.
1999
2187
  const owed = this.#unansweredCalls(restored);
2000
2188
  if (owed.length > 0) {
2001
- this.#pendingTools = owed.map((call, index) => ({
2002
- key: this.#toolKey(index, call.callId),
2003
- call,
2004
- }));
2189
+ this.#pendingTools = owed.map((call, index) => this.#newToolEntry(index, call));
2005
2190
  this.#pendingToolsUndispatched = true;
2006
2191
  }
2007
2192
  }
@@ -2021,16 +2206,16 @@ export class AgentBase {
2021
2206
  if (!resume) {
2022
2207
  await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
2023
2208
  for (const entry of entries) {
2024
- await this.#persistence.writeValue(txCtx, entry.key, entry.call);
2209
+ await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry(entry));
2025
2210
  }
2026
2211
  // The batch and the stage that describes it commit together. A crash can
2027
2212
  // then never find calls owed with no record of a run owing them, nor a run
2028
2213
  // recorded as running tools that were never written.
2029
- await this.#recordPending(txCtx, { stage: "tools" });
2214
+ await this.#recordPending(txCtx, "tools");
2030
2215
  // Last, so a hook noting a call about to happen sees a transaction holding
2031
2216
  // the whole batch it belongs to.
2032
2217
  for (const entry of entries) {
2033
- await this.#invokeToolTransactHook(txCtx, entry.call.callId, this.#hooks.beforeToolCallTransact, entry.call);
2218
+ await this.#invokeToolTransactHook(txCtx, entry.id, entry.providerCallId, this.#hooks.beforeToolCallTransact, entry.call);
2034
2219
  }
2035
2220
  }));
2036
2221
  }
@@ -2054,29 +2239,43 @@ export class AgentBase {
2054
2239
  await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
2055
2240
  while (committed < entries.length) {
2056
2241
  const entry = entries[committed];
2057
- const result = results[committed];
2058
- if (entry === undefined || result === undefined)
2242
+ const proposed = results[committed];
2243
+ if (entry === undefined || proposed === undefined)
2059
2244
  return;
2245
+ let winner = proposed;
2060
2246
  await this.#recordTransaction(lockCtx, async (txCtx) => {
2247
+ const resultKey = this.#toolResultKey(entry.id);
2248
+ await this.#persistence.writeValueIfAbsent(txCtx, resultKey, proposed);
2249
+ const claims = await this.#persistence.readValues(txCtx, resultKey);
2250
+ const stored = claims.find(({ key }) => key === resultKey)?.value;
2251
+ if (!Value.Check(storedToolResultSchema, stored)) {
2252
+ throw new Error(`The committed result claim for tool "${entry.id}" is not valid.`);
2253
+ }
2254
+ const result = stored;
2255
+ if (result.callId !== entry.providerCallId) {
2256
+ throw new Error(`The committed result claim for tool "${entry.id}" has the wrong provider call ID.`);
2257
+ }
2258
+ winner = result;
2259
+ results[committed] = result;
2061
2260
  await this.#appendRecord(txCtx, {
2062
2261
  type: "tool",
2063
2262
  message: result,
2064
2263
  });
2065
- // The call is answered, so what was kept only to let it be retried
2066
- // goes with it. What the tool itself wrote under its own call scope
2067
- // stays: that is the tool's state, not the batch's bookkeeping, and
2068
- // an owner may still want to read what a finished call recorded.
2264
+ // The call is answered, so both its retry record and its temporary
2265
+ // invocation store disappear in this same result transaction.
2069
2266
  await this.#persistence.deleteValue(txCtx, entry.key);
2070
- await this.#invokeToolTransactHook(txCtx, entry.call.callId, this.#hooks.afterToolCallTransact, result);
2267
+ await this.#persistence.deleteValue(txCtx, resultKey);
2268
+ await this.#invokeToolTransactHook(txCtx, entry.id, entry.providerCallId, this.#hooks.afterToolCallTransact, result);
2269
+ await this.#kv.scoped("call", entry.id).clear(txCtx);
2071
2270
  });
2072
- this.#messages.push(result);
2271
+ this.#messages.push(winner);
2073
2272
  committed += 1;
2074
2273
  }
2075
2274
  // The batch is fully answered, so its results are what the model is owed a
2076
2275
  // response to. Recording that here means a crash between the last result and
2077
2276
  // the next request resumes as an inference rather than as a finished batch.
2078
2277
  if (committed === entries.length) {
2079
- await this.#recordPending(lockCtx, { stage: "inference" });
2278
+ await this.#recordPending(lockCtx, "inference");
2080
2279
  }
2081
2280
  });
2082
2281
  }
@@ -2088,12 +2287,15 @@ export class AgentBase {
2088
2287
  this.#toolsRunning += 1;
2089
2288
  const batch = Promise.all(entries.map(async (entry, index) => {
2090
2289
  let outcome;
2091
- if (resume && !(await this.#isDurable(entry.call))) {
2290
+ if (entry.committed !== undefined) {
2291
+ outcome = entry.committed;
2292
+ }
2293
+ else if (resume && !(await this.#isDurable(entry.call))) {
2092
2294
  outcome = toolFailure(entry.call.callId, "The tool call was interrupted by a restart and was not retried.");
2093
2295
  }
2094
2296
  else {
2095
2297
  const toolLifetime = AbortSignal.any([signal, this.#closeController.signal]);
2096
- const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry.call);
2298
+ const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry);
2097
2299
  running.push(execution);
2098
2300
  outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
2099
2301
  }
@@ -2160,29 +2362,77 @@ export class AgentBase {
2160
2362
  return [];
2161
2363
  return last.content.filter((block) => block.type === "tool_call" && block.server !== true);
2162
2364
  }
2365
+ /** Allocate one internal identity before the call becomes durable or executable. */
2366
+ #newToolEntry(index, call) {
2367
+ const id = createId();
2368
+ return {
2369
+ key: this.#toolKey(index, id),
2370
+ id,
2371
+ providerCallId: call.callId,
2372
+ call,
2373
+ };
2374
+ }
2375
+ /** Restore the exact internal/provider identity pair that was dispatched before a restart. */
2376
+ #restoreToolEntry(key, value) {
2377
+ if (!Value.Check(storedToolCallSchema, value)) {
2378
+ throw new Error(`The pending tool call under "${key}" is not valid.`);
2379
+ }
2380
+ const stored = value;
2381
+ if (stored.committed !== undefined &&
2382
+ !Value.Check(storedToolResultSchema, stored.committed)) {
2383
+ throw new Error(`The committed result under "${key}" is not valid.`);
2384
+ }
2385
+ const call = {
2386
+ ...stored.call,
2387
+ callId: stored.providerCallId,
2388
+ };
2389
+ return {
2390
+ key,
2391
+ id: stored.id,
2392
+ providerCallId: stored.providerCallId,
2393
+ call,
2394
+ ...(stored.committed === undefined
2395
+ ? {}
2396
+ : { committed: stored.committed }),
2397
+ };
2398
+ }
2399
+ /** The explicit durable representation keeps provider and internal identities separate. */
2400
+ #storedToolEntry(entry) {
2401
+ const { callId: _providerCallId, server: _server, ...call } = entry.call;
2402
+ return {
2403
+ id: entry.id,
2404
+ providerCallId: entry.providerCallId,
2405
+ call,
2406
+ ...(entry.committed === undefined ? {} : { committed: entry.committed }),
2407
+ };
2408
+ }
2163
2409
  /** Sorted by position in the batch; only one batch is ever pending at a time. */
2164
- #toolKey(index, callId) {
2165
- return `tool.${String(index).padStart(6, "0")}.${callId}`;
2410
+ #toolKey(index, id) {
2411
+ return `tool.${String(index).padStart(6, "0")}.${id}`;
2412
+ }
2413
+ /** The first-writer-wins durable claim shared by tool commit and ordinary settlement. */
2414
+ #toolResultKey(id) {
2415
+ return `toolResult.${id}`;
2166
2416
  }
2167
2417
  /**
2168
- * The scope one call owns: state persists under its own call ID, never in another call's
2169
- * scope, and the task context ends where the call was made. The execution and all four tool
2170
- * hooks share it, so what one of them writes about a call is where the others look for it.
2418
+ * The scope one call owns: state lives under its internal ID, never under the provider's
2419
+ * opaque ID or another call's scope, and is erased when the call commits. The task context
2420
+ * still locates the provider call in conversation history.
2171
2421
  */
2172
- #callScoped(ctx, callId) {
2173
- return withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call", callId)), this.#runKV.scoped("call", callId)), taskContextBeforeToolCall(this.#messages, callId));
2422
+ #callScoped(ctx, id, providerCallId) {
2423
+ return withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call", id)), this.#runKV.scoped("call", id)), taskContextBeforeToolCall(this.#messages, providerCallId));
2174
2424
  }
2175
2425
  /**
2176
2426
  * Call a tool hook that writes inside a transaction of its own call's. The lifetime ends with
2177
2427
  * the callback, so a context kept afterwards cannot outlive the transaction it belongs to.
2178
2428
  * Its failure is not contained: it rolls that transaction back.
2179
2429
  */
2180
- async #invokeToolTransactHook(txCtx, callId, hook, argument) {
2430
+ async #invokeToolTransactHook(txCtx, id, providerCallId, hook, argument) {
2181
2431
  if (hook === undefined)
2182
2432
  return;
2183
2433
  const lifetime = new AbortController();
2184
2434
  try {
2185
- await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal), callId), argument);
2435
+ await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal), id, providerCallId), argument);
2186
2436
  }
2187
2437
  finally {
2188
2438
  lifetime.abort();
@@ -2193,7 +2443,8 @@ export class AgentBase {
2193
2443
  * The context carries the turn's abort signal as its lifetime, so a running tool can
2194
2444
  * observe cancellation and stop its own work.
2195
2445
  */
2196
- async #executeToolCall(ctx, call) {
2446
+ async #executeToolCall(ctx, entry) {
2447
+ const { call } = entry;
2197
2448
  const failure = (text) => ({
2198
2449
  role: "tool",
2199
2450
  callId: call.callId,
@@ -2217,13 +2468,90 @@ export class AgentBase {
2217
2468
  if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
2218
2469
  return failure(`The arguments for "${call.name}" did not match its schema.`);
2219
2470
  }
2220
- const callCtx = this.#callScoped(ctx, call.callId);
2471
+ const callKV = this.#kv.scoped("call", entry.id).serialized();
2472
+ const callLifetime = new AbortController();
2473
+ let committing = false;
2474
+ const boundedCallKV = callKV.until(callLifetime.signal, () => !committing);
2475
+ const callCtx = withAgentKV(this.#callScoped(ctx, entry.id, entry.providerCallId), boundedCallKV);
2221
2476
  // From here the call is one the two tool hooks bracket: a tool that exists, a call that
2222
2477
  // finished, and arguments its schema accepts. A call refused before that reaches neither
2223
2478
  // hook, because there is nothing yet to decide about or to report.
2224
2479
  let ran = tool;
2225
2480
  let ranArguments = args;
2226
2481
  let outcome;
2482
+ let committedOutcome;
2483
+ let commitAttempt;
2484
+ let resolveCommitted;
2485
+ const committed = new Promise((resolve) => {
2486
+ resolveCommitted = resolve;
2487
+ });
2488
+ const outcomeFor = (result) => ({
2489
+ callId: call.callId,
2490
+ tool: ran,
2491
+ arguments: ranArguments,
2492
+ content: [...ran.toLLM(result)],
2493
+ isError: ran.isError?.(result) === true,
2494
+ result,
2495
+ });
2496
+ const commit = async (commitCtx, result) => {
2497
+ if (commitAttempt !== undefined)
2498
+ return await commitAttempt;
2499
+ if (callLifetime.signal.aborted || commitCtx.lifetime?.aborted === true) {
2500
+ throw new Error("The tool call can no longer commit a result.");
2501
+ }
2502
+ if (!Value.Check(ran.returnType, result)) {
2503
+ throw new Error(`Tool "${ran.name}" committed an invalid result.`);
2504
+ }
2505
+ const candidate = outcomeFor(result);
2506
+ const message = {
2507
+ role: "tool",
2508
+ callId: entry.providerCallId,
2509
+ content: candidate.content,
2510
+ ...(candidate.isError ? { isError: true } : {}),
2511
+ };
2512
+ committing = true;
2513
+ const attempt = (async () => {
2514
+ if (agentKV(commitCtx)?.prefix !== callKV.prefix) {
2515
+ throw new Error("A tool result can only be committed with its own live call context.");
2516
+ }
2517
+ await this.#persistence.transaction(commitCtx, async (txCtx) => {
2518
+ const claimed = await this.#persistence.writeValueIfAbsent(txCtx, this.#toolResultKey(entry.id), message);
2519
+ if (!claimed) {
2520
+ throw new Error("The tool call already has a committed result.");
2521
+ }
2522
+ const pending = await this.#persistence.readValues(txCtx, entry.key);
2523
+ const stored = pending.find(({ key }) => key === entry.key);
2524
+ if (stored === undefined) {
2525
+ throw new Error("The tool call has already been settled.");
2526
+ }
2527
+ const current = this.#restoreToolEntry(entry.key, stored.value);
2528
+ if (current.id !== entry.id) {
2529
+ throw new Error("The pending tool identity changed before commit.");
2530
+ }
2531
+ if (current.committed === undefined) {
2532
+ await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry({ ...entry, committed: message }));
2533
+ await callKV.clear(txCtx);
2534
+ }
2535
+ afterCommit(txCtx, () => {
2536
+ committedOutcome = candidate;
2537
+ callLifetime.abort();
2538
+ resolveCommitted(candidate);
2539
+ });
2540
+ });
2541
+ return result;
2542
+ })();
2543
+ commitAttempt = attempt;
2544
+ try {
2545
+ return await attempt;
2546
+ }
2547
+ catch (error) {
2548
+ if (commitAttempt === attempt) {
2549
+ commitAttempt = undefined;
2550
+ committing = false;
2551
+ }
2552
+ throw error;
2553
+ }
2554
+ };
2227
2555
  try {
2228
2556
  const decision = await this.#hooks.beforeToolCall?.(callCtx, {
2229
2557
  callId: call.callId,
@@ -2256,38 +2584,64 @@ export class AgentBase {
2256
2584
  const runCtx = decision?.permissionMode === undefined
2257
2585
  ? callCtx
2258
2586
  : withAgentPermissionMode(callCtx, decision.permissionMode);
2259
- const result = await ran.execute(runCtx, ranArguments);
2260
- if (!Value.Check(ran.returnType, result)) {
2261
- throw new Error(`Tool "${ran.name}" returned an invalid result.`);
2587
+ const executionCtx = withLifetime(runCtx, AbortSignal.any(runCtx.lifetime === undefined
2588
+ ? [callLifetime.signal]
2589
+ : [runCtx.lifetime, callLifetime.signal]));
2590
+ const execution = Promise.resolve(ran.execute(executionCtx, ranArguments, {
2591
+ id: entry.id,
2592
+ providerCallId: entry.providerCallId,
2593
+ kv: boundedCallKV,
2594
+ commit,
2595
+ })).then((result) => ({ type: "returned", result }), (error) => ({ type: "threw", error }));
2596
+ const settled = await Promise.race([
2597
+ execution,
2598
+ committed.then((committed) => ({ type: "committed", committed })),
2599
+ ]);
2600
+ if (settled.type === "committed") {
2601
+ outcome = settled.committed;
2602
+ }
2603
+ else if (settled.type === "threw") {
2604
+ throw settled.error;
2605
+ }
2606
+ else if (committedOutcome !== undefined) {
2607
+ outcome = committedOutcome;
2608
+ }
2609
+ else {
2610
+ if (!Value.Check(ran.returnType, settled.result)) {
2611
+ throw new Error(`Tool "${ran.name}" returned an invalid result.`);
2612
+ }
2613
+ outcome = outcomeFor(settled.result);
2262
2614
  }
2263
- outcome = {
2264
- callId: call.callId,
2265
- tool: ran,
2266
- arguments: ranArguments,
2267
- content: [...ran.toLLM(result)],
2268
- isError: ran.isError?.(result) === true,
2269
- result,
2270
- };
2271
2615
  }
2272
2616
  }
2273
2617
  catch (error) {
2274
- outcome = {
2618
+ outcome =
2619
+ committedOutcome ??
2620
+ {
2621
+ callId: call.callId,
2622
+ tool: ran,
2623
+ arguments: ranArguments,
2624
+ content: [
2625
+ {
2626
+ type: "text",
2627
+ text: error instanceof Error ? error.message : String(error),
2628
+ },
2629
+ ],
2630
+ isError: true,
2631
+ };
2632
+ }
2633
+ try {
2634
+ await this.#invokeHookOn(callCtx, this.#hooks.afterToolCall, outcome);
2635
+ return {
2636
+ role: "tool",
2275
2637
  callId: call.callId,
2276
- tool: ran,
2277
- arguments: ranArguments,
2278
- content: [
2279
- { type: "text", text: error instanceof Error ? error.message : String(error) },
2280
- ],
2281
- isError: true,
2638
+ content: outcome.content,
2639
+ ...(outcome.isError ? { isError: true } : {}),
2282
2640
  };
2283
2641
  }
2284
- await this.#invokeHookOn(callCtx, this.#hooks.afterToolCall, outcome);
2285
- return {
2286
- role: "tool",
2287
- callId: call.callId,
2288
- content: outcome.content,
2289
- ...(outcome.isError ? { isError: true } : {}),
2290
- };
2642
+ finally {
2643
+ callLifetime.abort();
2644
+ }
2291
2645
  }
2292
2646
  /**
2293
2647
  * A key that sorts after every entry the queue already holds. The order comes from the store