@slopus/happy-agent-base 0.0.5 → 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 (92) hide show
  1. package/README.md +62 -9
  2. package/dist/Agent.d.ts +27 -22
  3. package/dist/Agent.d.ts.map +1 -1
  4. package/dist/Agent.js +120 -110
  5. package/dist/Agent.js.map +1 -1
  6. package/dist/AgentBase.d.ts +27 -13
  7. package/dist/AgentBase.d.ts.map +1 -1
  8. package/dist/AgentBase.js +657 -151
  9. package/dist/AgentBase.js.map +1 -1
  10. package/dist/AgentBaseHooks.d.ts +58 -32
  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 +20 -13
  17. package/dist/AgentConfig.d.ts.map +1 -1
  18. package/dist/AgentConfig.js +32 -12
  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 +37 -0
  37. package/dist/AgentMetadata.d.ts.map +1 -0
  38. package/dist/AgentMetadata.js +74 -0
  39. package/dist/AgentMetadata.js.map +1 -0
  40. package/dist/AgentModule.d.ts +209 -0
  41. package/dist/AgentModule.d.ts.map +1 -0
  42. package/dist/AgentModule.js +2 -0
  43. package/dist/AgentModule.js.map +1 -0
  44. package/dist/{AgentFeatureAction.d.ts → AgentModuleAction.d.ts} +7 -2
  45. package/dist/AgentModuleAction.d.ts.map +1 -0
  46. package/dist/AgentModuleAction.js +2 -0
  47. package/dist/AgentModuleAction.js.map +1 -0
  48. package/dist/AgentPermissionMode.d.ts +1 -1
  49. package/dist/AgentPermissionMode.js +1 -1
  50. package/dist/AgentPersistence.d.ts +20 -7
  51. package/dist/AgentPersistence.d.ts.map +1 -1
  52. package/dist/AgentPersistenceDrizzle.d.ts +31 -0
  53. package/dist/AgentPersistenceDrizzle.d.ts.map +1 -0
  54. package/dist/AgentPersistenceDrizzle.js +99 -0
  55. package/dist/AgentPersistenceDrizzle.js.map +1 -0
  56. package/dist/AgentRef.d.ts +17 -9
  57. package/dist/AgentRef.d.ts.map +1 -1
  58. package/dist/AgentRef.js +16 -9
  59. package/dist/AgentRef.js.map +1 -1
  60. package/dist/AgentStorage.d.ts +33 -13
  61. package/dist/AgentStorage.d.ts.map +1 -1
  62. package/dist/AgentStorage.js +174 -3
  63. package/dist/AgentStorage.js.map +1 -1
  64. package/dist/AgentSystem.d.ts +34 -12
  65. package/dist/AgentSystem.d.ts.map +1 -1
  66. package/dist/AgentSystemContext.d.ts +3 -3
  67. package/dist/AgentSystemContext.d.ts.map +1 -1
  68. package/dist/AgentSystemContext.js +4 -4
  69. package/dist/AgentSystemContext.js.map +1 -1
  70. package/dist/AgentSystemLocal.d.ts +30 -21
  71. package/dist/AgentSystemLocal.d.ts.map +1 -1
  72. package/dist/AgentSystemLocal.js +226 -59
  73. package/dist/AgentSystemLocal.js.map +1 -1
  74. package/dist/AgentSystemRef.d.ts +21 -10
  75. package/dist/AgentSystemRef.d.ts.map +1 -1
  76. package/dist/AgentSystemRef.js +41 -9
  77. package/dist/AgentSystemRef.js.map +1 -1
  78. package/dist/AgentTool.d.ts +16 -1
  79. package/dist/AgentTool.d.ts.map +1 -1
  80. package/dist/AgentTool.js.map +1 -1
  81. package/dist/index.d.ts +10 -7
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +9 -6
  84. package/dist/index.js.map +1 -1
  85. package/package.json +4 -3
  86. package/dist/AgentFeature.d.ts +0 -177
  87. package/dist/AgentFeature.d.ts.map +0 -1
  88. package/dist/AgentFeature.js +0 -2
  89. package/dist/AgentFeature.js.map +0 -1
  90. package/dist/AgentFeatureAction.d.ts.map +0 -1
  91. package/dist/AgentFeatureAction.js +0 -2
  92. package/dist/AgentFeatureAction.js.map +0 -1
package/dist/AgentBase.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import { areProviderModelsCompatible } from "@slopus/happy-providers";
2
+ import { createId } from "@paralleldrive/cuid2";
2
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
+ import { Type } from "@sinclair/typebox";
3
5
  import { Value } from "@sinclair/typebox/value";
4
- import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
5
- 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";
8
+ import { agentConfig, ownAgentConfig, withAgentConfig } from "./AgentConfig.js";
6
9
  import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
7
10
  import { AgentKV } from "./AgentKV.js";
8
11
  import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
12
+ import { cuid2Schema, ownAgentMessageMetadata, ownAgentMetadata, } from "./AgentMetadata.js";
9
13
  import { DEFAULT_AGENT_PERMISSION_MODE, isAgentPermissionMode, } from "./AgentPermissionMode.js";
10
14
  import { AgentProviders } from "./AgentProviders.js";
11
15
  /** Race winner when an abort interrupts a wait on the stream or a running tool. */
@@ -25,12 +29,35 @@ const insideTurn = createContextNamespace("agentInsideTurn", []);
25
29
  * its own and outlives whatever happened to start it.
26
30
  */
27
31
  const insideLoops = new AsyncLocalStorage();
32
+ /** Persistence locks held by the current asynchronous call chain, independent of Context. */
33
+ const insidePersistenceLocks = new AsyncLocalStorage();
28
34
  /**
29
35
  * How long a close asked for from inside the agent's own run loop waits for the shutdown before
30
36
  * telling its caller it cannot be waited for. Long enough that a caller which has already let go
31
37
  * hears the shutdown finish, short enough that one still holding the loop is told promptly.
32
38
  */
33
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
+ });
34
61
  /**
35
62
  * A single agent session over one provider. Messages arrive through two FIFO queues: steering
36
63
  * messages inject as soon as the current assistant response and its tool batch finish, while
@@ -114,7 +141,7 @@ const INSIDE_CLOSE_REPORT_MS = 15;
114
141
  * running under rather than being told.
115
142
  *
116
143
  * The loop enforces nothing. It has no idea what any particular tool touches, and a runtime that
117
- * 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
118
145
  * tools that do know; the loop's whole part is to carry the mode, make its changes durable, and
119
146
  * report them.
120
147
  *
@@ -203,6 +230,8 @@ export class AgentBase {
203
230
  #baseCtx;
204
231
  /** The base context extended with the effective selection and the agent's key-value store. */
205
232
  #ctx;
233
+ /** The immutable configuration snapshot carried by every hook context. */
234
+ #config;
206
235
  /** The registry the provider ID is resolved through, each time a session is created. */
207
236
  #providers;
208
237
  /** The registry ID of the provider in force; durable, so a restart resumes on the same one. */
@@ -282,6 +311,13 @@ export class AgentBase {
282
311
  * about the agent is answered from here without touching the disk.
283
312
  */
284
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;
285
321
  /**
286
322
  * The pending state this instance last wrote, so a write that would change nothing is
287
323
  * skipped. The loop passes through the same stage many times in a turn, and a store is not
@@ -319,6 +355,8 @@ export class AgentBase {
319
355
  #contextTokens;
320
356
  /** Whether the current turn was cancelled before it could finish. */
321
357
  #turnAborted = false;
358
+ /** A staged tool result could not settle, so this run must leave its pending state intact. */
359
+ #durableWorkBlocked = false;
322
360
  /** Whether something has asked for a turn that has not been answered yet. */
323
361
  #turnRequested = false;
324
362
  /** The run loop while it is running; the field is cleared once it has actually stopped. */
@@ -343,6 +381,8 @@ export class AgentBase {
343
381
  * owners of one session at once.
344
382
  */
345
383
  #streamCleanup = new Set();
384
+ /** IDs offered by this process, for immediate self-reentrant acceptance answers. */
385
+ #offeredMessageIds = new Set();
346
386
  /**
347
387
  * A new agent, wired to its options and touching no storage at all. Use this for an identity
348
388
  * with no durable state yet; whatever the agent needs from the store is read by its first
@@ -388,11 +428,16 @@ export class AgentBase {
388
428
  * by, so reading it here leaves the later stage writes nothing to learn from the store.
389
429
  */
390
430
  async #loadPendingState() {
391
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
431
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
432
+ await this.#loadConfig(lockCtx);
392
433
  const stored = await agentBasePendingStateOf(lockCtx, this.#persistence);
393
434
  this.#inherited = stored;
394
435
  this.#inheritedRead = true;
395
436
  this.#pending = stored;
437
+ this.#loopId = stored?.loopId;
438
+ this.#turnId = stored?.turnId;
439
+ this.#inferenceId = stored?.inferenceId;
440
+ this.#settlementId = stored?.settlementId;
396
441
  this.#pendingWritten =
397
442
  stored === undefined ? undefined : deterministicStringify(stored);
398
443
  });
@@ -405,10 +450,11 @@ export class AgentBase {
405
450
  */
406
451
  constructor(ctx, options) {
407
452
  this.id = options.id;
453
+ this.#config = ownAgentConfig(agentConfig(ctx) ?? {});
408
454
  // An agent is its own lifetime. Whatever call happened to construct it — a tool of
409
455
  // another agent, most often — is not a loop this one runs inside, so an inherited
410
456
  // marker is dropped rather than carried into work that outlives that call.
411
- this.#baseCtx = insideTurn.set(ctx, [options.id]);
457
+ this.#baseCtx = withoutAgentStorageTransaction(withAgentDatabase(withAgentConfig(insideTurn.set(ctx, [options.id]), this.#config), options.persistence.database));
412
458
  this.#providers = options.providers;
413
459
  this.#providerId = options.provider;
414
460
  this.#persistence = options.persistence;
@@ -436,8 +482,24 @@ export class AgentBase {
436
482
  * the selection changes.
437
483
  */
438
484
  #deriveCtx() {
439
- const ctx = withAgentContext(this.#baseCtx, this.#selection());
440
- return withAgentRunKV(withAgentKV(ctx, this.#kv), this.#runKV);
485
+ return this.#hookContext(this.#baseCtx);
486
+ }
487
+ /** Add this agent's selection and stores to a caller context without losing its transaction. */
488
+ #hookContext(ctx) {
489
+ const selected = withAgentContext(ctx, this.#selection());
490
+ const database = agentDatabase(ctx) ?? this.#persistence.database;
491
+ return withAgentRunKV(withAgentKV(withAgentDatabase(selected, database), this.#kv), this.#runKV);
492
+ }
493
+ /** Load a directly owned configuration written by `updateMetadata`, when one exists. */
494
+ async #loadConfig(ctx) {
495
+ const stored = await this.#persistence.readValues(ctx, "agentConfig");
496
+ const exact = stored.find(({ key }) => key === "agentConfig")?.value;
497
+ if (exact === undefined)
498
+ return;
499
+ const config = ownAgentConfig(exact);
500
+ this.#config = config;
501
+ this.#baseCtx = withAgentConfig(this.#baseCtx, config);
502
+ this.#ctx = this.#deriveCtx();
441
503
  }
442
504
  /** Everything about what the agent is currently running on, as one value to carry. */
443
505
  #selection() {
@@ -465,7 +527,8 @@ export class AgentBase {
465
527
  * whatever else that transaction is writing, which is how a consumed message and the
466
528
  * inference it owes become durable as one fact rather than two.
467
529
  */
468
- async #recordPending(ctx, pending, force = false) {
530
+ async #recordPending(ctx, stage, force = false) {
531
+ const pending = this.#pendingState(stage);
469
532
  const serialized = deterministicStringify(pending);
470
533
  if (!force && this.#pendingWritten === serialized)
471
534
  return;
@@ -473,6 +536,17 @@ export class AgentBase {
473
536
  this.#pending = pending;
474
537
  this.#pendingWritten = serialized;
475
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
+ }
476
550
  /**
477
551
  * Record the stage the run has reached, taking the store lock when not already inside it.
478
552
  *
@@ -482,27 +556,27 @@ export class AgentBase {
482
556
  * starting here.
483
557
  */
484
558
  async #enterStage(stage, transact) {
485
- const pending = { stage };
559
+ const pending = this.#pendingState(stage);
486
560
  if (transact === undefined &&
487
561
  deterministicStringify(pending) === this.#pendingWritten &&
488
562
  this.#inheritedRead) {
489
563
  return undefined;
490
564
  }
491
565
  try {
492
- return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
566
+ return await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
493
567
  if (!this.#inheritedRead) {
494
568
  this.#inheritedRead = true;
495
569
  this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
496
570
  }
497
571
  if (transact === undefined) {
498
- await this.#recordPending(lockCtx, pending);
572
+ await this.#recordPending(lockCtx, stage);
499
573
  return undefined;
500
574
  }
501
575
  return await this.#recordTransaction(lockCtx, async (txCtx) => {
502
576
  // The state is staged first. The callback then writes against this exact
503
577
  // transaction, so neither its conclusion nor the state it observed can land
504
578
  // without the other.
505
- await this.#recordPending(txCtx, pending, true);
579
+ await this.#recordPending(txCtx, stage, true);
506
580
  return await this.#withTransactionalContext(txCtx, transact);
507
581
  });
508
582
  });
@@ -528,22 +602,64 @@ export class AgentBase {
528
602
  /**
529
603
  * Queue a user message that injects as soon as the current assistant response and its tool
530
604
  * batch finish; steering always takes precedence over sent messages. Returns once the message
531
- * has been handed to the agent, which never waits for the turn that answers it; with
532
- * `await: true` it returns once the durable write has landed instead, and a failed write both
533
- * 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.
534
608
  */
535
609
  async steer(ctx, message, options) {
536
- await this.#offer(ctx, "steering", message, options);
610
+ return await this.#offer(ctx, "steering", message, options);
537
611
  }
538
612
  /**
539
613
  * Queue a user message that waits until the agent would otherwise stop — no tool calls or
540
614
  * steering remain — before injecting. Returns once the message has been handed to the agent,
541
- * which never waits for the turn that answers it; with `await: true` it returns once the
542
- * durable write has landed instead, and a failed write both rejects and keeps the message out
543
- * 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.
544
618
  */
545
619
  async send(ctx, message, options) {
546
- await this.#offer(ctx, "send", message, options);
620
+ return await this.#offer(ctx, "send", message, options);
621
+ }
622
+ /**
623
+ * Shallow-merge fields into this agent's immutable metadata. The complete AgentConfig and
624
+ * transactional hook writes commit together; observing hooks run only after that commit.
625
+ */
626
+ async updateMetadata(ctx, update) {
627
+ this.#assertOutsideStorageTransaction(ctx, "metadata update");
628
+ const ownedUpdate = ownAgentMetadata(update);
629
+ if (ownedUpdate === undefined)
630
+ throw new Error("The agent metadata is not valid.");
631
+ if (this.#closed)
632
+ throw new Error("The agent has been closed.");
633
+ if (insideTurn.get(ctx).includes(this.id) ||
634
+ this.#insideOwnLoop() ||
635
+ this.#insideOwnPersistenceLock()) {
636
+ throw new Error("Updating metadata from inside this agent's current operation would wait for " +
637
+ "that same operation to finish. Update it after the hook or tool returns.");
638
+ }
639
+ let change;
640
+ let next;
641
+ await this.#runInPersistenceLock(ctx, async (lockCtx) => {
642
+ const previousMetadata = ownAgentMetadata(this.#config.metadata ?? {});
643
+ const metadata = ownAgentMetadata({ ...previousMetadata, ...ownedUpdate });
644
+ if (previousMetadata === undefined || metadata === undefined) {
645
+ throw new Error("The agent metadata is not valid.");
646
+ }
647
+ next = ownAgentConfig({ ...this.#config, metadata });
648
+ change = {
649
+ agentId: this.id,
650
+ previousMetadata,
651
+ update: ownedUpdate,
652
+ metadata,
653
+ };
654
+ await this.#persistence.transaction(lockCtx, async (txCtx) => {
655
+ await this.#persistence.writeValue(txCtx, "agentConfig", next);
656
+ await this.#withTransactionalContext(withAgentConfig(txCtx, next), async (hookCtx) => await this.#hooks.metadataChangedTransact?.(this.#hookContext(hookCtx), change));
657
+ });
658
+ this.#config = next;
659
+ this.#baseCtx = withAgentConfig(this.#baseCtx, next);
660
+ this.#ctx = this.#deriveCtx();
661
+ });
662
+ await this.#invokeHookOn(this.#hookContext(withAgentConfig(ctx, next)), this.#hooks.metadataChanged, change);
547
663
  }
548
664
  /**
549
665
  * Hand one message to a durable queue. The acceptance runs whether or not the caller waits
@@ -551,16 +667,52 @@ export class AgentBase {
551
667
  * the agent's own close still drains it, so nothing is dropped by not looking.
552
668
  */
553
669
  async #offer(ctx, kind, message, options) {
554
- const { await: wait = false, ...settings } = options ?? {};
670
+ this.#assertOutsideStorageTransaction(ctx, "message delivery");
671
+ const { await: requestedWait, id = createId(), metadata: suppliedMetadata, ...settings } = options ?? {};
672
+ if (!Value.Check(cuid2Schema, id)) {
673
+ throw new Error("The message ID must be a cuid2 identity.");
674
+ }
675
+ const wait = requestedWait ?? !insideTurn.get(ctx).includes(this.id);
676
+ const metadata = ownAgentMessageMetadata(suppliedMetadata);
555
677
  // Refusing the flag rather than the operation: a closed agent and a re-entrant wait are
556
678
  // both caller mistakes, and both are reported before any work is started.
557
679
  this.#assertCanWait(ctx, wait, kind === "steering" ? "a steered message" : "a sent message");
558
680
  if (this.#closed)
559
681
  throw new Error("The agent has been closed.");
560
- const accepted = this.#enqueue(ctx, [{ kind, message, options: settings }]);
561
- if (wait)
562
- return accepted;
563
- accepted.catch(() => undefined);
682
+ const knownInProcess = this.#offeredMessageIds.has(id);
683
+ this.#offeredMessageIds.add(id);
684
+ const accepted = this.#enqueue(ctx, [
685
+ {
686
+ kind,
687
+ id,
688
+ message: structuredClone(message),
689
+ ...(metadata === undefined ? {} : { metadata }),
690
+ options: settings,
691
+ },
692
+ ]);
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
+ };
564
716
  }
565
717
  /**
566
718
  * Refuse a wait that could never end. A hook or a tool runs while its agent's loop waits for
@@ -585,6 +737,25 @@ export class AgentBase {
585
737
  #insideOwnLoop() {
586
738
  return insideLoops.getStore()?.includes(this.id) === true;
587
739
  }
740
+ /** Whether this call chain already holds this agent's persistence lock. */
741
+ #insideOwnPersistenceLock() {
742
+ return insidePersistenceLocks.getStore()?.includes(this.id) === true;
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
+ }
750
+ /** Hold the persistence lock while marking it independently of the caller's Context. */
751
+ async #runInPersistenceLock(ctx, work) {
752
+ return await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
753
+ const held = insidePersistenceLocks.getStore() ?? [];
754
+ return await insidePersistenceLocks.run([...held, this.id], async () => {
755
+ return await work(lockCtx);
756
+ });
757
+ });
758
+ }
588
759
  /**
589
760
  * Accept a batch of messages as one durable step. Every message is written under the same
590
761
  * hold of the persistence lock and inside one transaction, so a caller arriving while a
@@ -593,40 +764,68 @@ export class AgentBase {
593
764
  */
594
765
  async #enqueue(ctx, batch) {
595
766
  if (batch.length === 0)
596
- return;
767
+ return [];
597
768
  if (this.#closed)
598
769
  throw new Error("The agent has been closed.");
599
770
  // Admitted: from here on the messages are the agent's responsibility, and a close that
600
771
  // begins now waits for them rather than resolving over the top of them.
601
- const admitted = this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
772
+ const admitted = this.#runInPersistenceLock(ctx, async (lockCtx) => {
602
773
  const accepted = [];
603
- await this.#persistence.transaction(lockCtx, async (txCtx) => {
774
+ const results = [];
775
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
604
776
  for (const request of batch) {
777
+ const identityKey = `message.${request.id}`;
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
+ });
784
+ continue;
785
+ }
605
786
  const key = await this.#queueKey(txCtx, `${request.kind}.`);
606
787
  await this.#persistence.writeValue(txCtx, key, {
788
+ id: request.id,
607
789
  message: request.message,
790
+ ...(request.metadata === undefined ? {} : { metadata: request.metadata }),
608
791
  options: request.options,
609
792
  });
610
793
  accepted.push({ key, request });
794
+ results.push({
795
+ id: request.id,
796
+ delivery: request.kind === "steering" ? "steer" : "send",
797
+ accepted: "created",
798
+ });
611
799
  }
800
+ if (accepted.length === 0)
801
+ return;
612
802
  // Accepting a message is what makes the work owed: the same transaction that
613
803
  // admits it records that the agent owes an answer, so a process that dies right
614
804
  // here is discovered still owing it rather than looking idle over a full queue.
615
- await this.#recordPending(txCtx, { stage: "inference" });
805
+ await this.#recordPending(txCtx, "inference");
616
806
  });
617
807
  for (const { key, request } of accepted) {
618
808
  // The queue is resolved inside the lock: a history load running just before this
619
809
  // one replaces the queue arrays wholesale, and a reference taken before the wait
620
810
  // would push the message into an array nobody reads again.
621
811
  const queue = request.kind === "steering" ? this.#steering : this.#sends;
622
- queue.push({ key, message: request.message, options: request.options });
812
+ queue.push({
813
+ key,
814
+ id: request.id,
815
+ message: request.message,
816
+ ...(request.metadata === undefined ? {} : { metadata: request.metadata }),
817
+ options: request.options,
818
+ });
623
819
  }
624
- this.#turnRequested = true;
625
- this.#startRun();
820
+ if (accepted.length > 0) {
821
+ this.#turnRequested = true;
822
+ this.#startRun();
823
+ }
824
+ return results;
626
825
  });
627
826
  this.#admitted.add(admitted);
628
827
  try {
629
- await admitted;
828
+ return await admitted;
630
829
  }
631
830
  finally {
632
831
  this.#admitted.delete(admitted);
@@ -665,6 +864,7 @@ export class AgentBase {
665
864
  * compaction rather than queueing another.
666
865
  */
667
866
  async compact(ctx, options) {
867
+ this.#assertOutsideStorageTransaction(ctx, "compaction");
668
868
  const wait = options?.await ?? false;
669
869
  this.#assertCanWait(ctx, wait, "a compaction");
670
870
  if (this.#closed)
@@ -790,6 +990,7 @@ export class AgentBase {
790
990
  * when it finished.
791
991
  */
792
992
  async abort(ctx, options) {
993
+ this.#assertOutsideStorageTransaction(ctx, "abort");
793
994
  const wait = options?.await ?? false;
794
995
  this.#assertCanWait(ctx, wait, "an abort");
795
996
  const run = this.#signalAbort();
@@ -932,9 +1133,20 @@ export class AgentBase {
932
1133
  * more work, so the loop hooks always bracket a settled-to-settled span.
933
1134
  */
934
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
+ }
935
1145
  // The outer loop reopens when an `afterAgentLoop` action requests more work, so the
936
1146
  // loop hooks always bracket a settled-to-settled span.
937
1147
  do {
1148
+ this.#loopId ??= createId();
1149
+ const loop = { loopId: this.#loopId };
938
1150
  // The abort scope opens before the loop hook, not just before the turn. An abort
939
1151
  // owns everything the run does — its opening hook as much as its inference — so a
940
1152
  // run cancelled while it is still starting up never reaches the model at all.
@@ -943,10 +1155,14 @@ export class AgentBase {
943
1155
  // crash could interrupt. What it records is refined as the run reaches each stage;
944
1156
  // what matters at this point is that the record exists at all, since its absence is
945
1157
  // what a later process reads as an agent that finished.
946
- await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact);
947
- 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);
948
1162
  do {
949
1163
  this.#turnAborted = false;
1164
+ this.#durableWorkBlocked = false;
1165
+ this.#turnId ??= createId();
950
1166
  // Claimed before any awaiting, so a request raised while the turn is still
951
1167
  // starting up survives into another turn instead of being cleared by it. The
952
1168
  // redundant turn this can cost is cheap: an empty queue drains without any
@@ -962,7 +1178,7 @@ export class AgentBase {
962
1178
  // durable exactly as it was for the next attempt.
963
1179
  const loadFailure = await this.#ensureLoaded().then(() => undefined, (error) => error);
964
1180
  if (loadFailure !== undefined) {
965
- this.#emit({
1181
+ await this.#emit({
966
1182
  type: "done",
967
1183
  state: "error",
968
1184
  kind: "internal_error",
@@ -970,9 +1186,12 @@ export class AgentBase {
970
1186
  ? loadFailure.message
971
1187
  : String(loadFailure),
972
1188
  });
1189
+ this.#turnId = undefined;
973
1190
  break;
974
1191
  }
975
1192
  const turnStart = {
1193
+ loopId: loop.loopId,
1194
+ turnId: this.#turnId,
976
1195
  contextTokens: this.#contextTokens,
977
1196
  };
978
1197
  await this.#enterStage("inference", this.#hooks.beforeTurnTransact === undefined
@@ -980,13 +1199,25 @@ export class AgentBase {
980
1199
  : (hookCtx) => this.#hooks.beforeTurnTransact?.(hookCtx, turnStart));
981
1200
  await this.#applyActions(this.#hooks.beforeTurn, abort.signal, turnStart);
982
1201
  await this.#runInference(abort);
1202
+ if (this.#durableWorkBlocked)
1203
+ return;
983
1204
  const turn = {
1205
+ loopId: loop.loopId,
1206
+ turnId: turnStart.turnId,
984
1207
  contextTokens: this.#contextTokens,
985
1208
  aborted: this.#turnAborted,
986
1209
  };
987
- await this.#enterStage("inference", this.#hooks.afterTurnTransact === undefined
988
- ? undefined
989
- : (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
+ }
990
1221
  await this.#applyActions(this.#hooks.afterTurn, abort.signal, turn);
991
1222
  if (!this.#turnRequested || this.#closed)
992
1223
  break;
@@ -994,14 +1225,21 @@ export class AgentBase {
994
1225
  // keeps the run's first turn under the scope its opening hook already ran in.
995
1226
  abort = this.#openAbortScope();
996
1227
  } while (true);
997
- await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact);
998
- 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);
999
1232
  } while (this.#turnRequested && !this.#closed);
1000
1233
  // Nothing is asked for any more, so the outstanding work is erased. That erasure is what
1001
1234
  // makes the agent idle, and it commits together with whatever the settling hooks write,
1002
1235
  // so no owner can ever see the agent finished without their conclusions or their
1003
1236
  // conclusions without the agent being finished.
1004
- 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
+ });
1005
1243
  }
1006
1244
  /**
1007
1245
  * Erase the outstanding work and let the transactional settling hooks write in the same
@@ -1009,17 +1247,22 @@ export class AgentBase {
1009
1247
  * is resumed and finds nothing to do, while one wrongly believed to be finished is never
1010
1248
  * resumed at all.
1011
1249
  */
1012
- async #settleDurably() {
1250
+ async #settleDurably(settlement) {
1013
1251
  try {
1014
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
1252
+ await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
1015
1253
  await this.#clearPending(txCtx);
1016
- await this.#invokeTransactionalSettle(txCtx);
1254
+ await this.#invokeTransactionalSettle(txCtx, settlement);
1017
1255
  // The run store is erased last, so a settling hook can still read what the
1018
1256
  // run concluded and keep whatever part of it belongs to the conversation.
1019
1257
  // It commits with the settlement: the run is over and its notes are gone as
1020
1258
  // one fact, never one without the other.
1021
1259
  await this.#clearRunStore(txCtx);
1022
1260
  }));
1261
+ this.#loopId = undefined;
1262
+ this.#turnId = undefined;
1263
+ this.#inferenceId = undefined;
1264
+ this.#settlementId = undefined;
1265
+ this.#settlement = settlement;
1023
1266
  }
1024
1267
  catch {
1025
1268
  // The run itself is over and succeeded; only the record of its ending failed.
@@ -1032,11 +1275,11 @@ export class AgentBase {
1032
1275
  * with it, because a hook here is writing a conclusion about the very fact being committed,
1033
1276
  * and half of that pair is worse than neither.
1034
1277
  */
1035
- async #invokeTransactionalSettle(txCtx) {
1278
+ async #invokeTransactionalSettle(txCtx, settlement) {
1036
1279
  const hook = this.#hooks.afterAgentSettledTransact;
1037
1280
  if (hook === undefined)
1038
1281
  return;
1039
- await this.#withTransactionalContext(insideTurn.set(txCtx, []), hook);
1282
+ await this.#withTransactionalContext(insideTurn.set(txCtx, []), (liveCtx) => hook(liveCtx, settlement));
1040
1283
  }
1041
1284
  /**
1042
1285
  * Erase everything the run wrote about itself, inside the transaction that settles the agent:
@@ -1069,7 +1312,11 @@ export class AgentBase {
1069
1312
  // The settle runs once the loop has stopped, so its hook is not inside a turn and
1070
1313
  // its context does not claim to be: a compaction it waits for reaches a loop that
1071
1314
  // can still be started.
1072
- 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
+ }
1073
1320
  })();
1074
1321
  this.#admitted.add(announced);
1075
1322
  void announced.finally(() => this.#admitted.delete(announced));
@@ -1088,7 +1335,7 @@ export class AgentBase {
1088
1335
  await this.#invokeHookOn(this.#ctx, hook, ...args);
1089
1336
  }
1090
1337
  /**
1091
- * 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
1092
1339
  * the context after the callback cannot leak a transaction past its commit.
1093
1340
  */
1094
1341
  async #withTransactionalContext(txCtx, work) {
@@ -1164,9 +1411,21 @@ export class AgentBase {
1164
1411
  this.#ensureCompaction().catch(() => undefined);
1165
1412
  continue;
1166
1413
  }
1414
+ const id = action.id ?? createId();
1415
+ if (!Value.Check(cuid2Schema, id))
1416
+ continue;
1417
+ let metadata;
1418
+ try {
1419
+ metadata = ownAgentMessageMetadata(action.metadata);
1420
+ }
1421
+ catch {
1422
+ continue;
1423
+ }
1167
1424
  batch.push({
1168
1425
  kind: action.type === "steer" ? "steering" : "send",
1169
- message: action.message,
1426
+ id,
1427
+ message: structuredClone(action.message),
1428
+ ...(metadata === undefined ? {} : { metadata }),
1170
1429
  options: {},
1171
1430
  });
1172
1431
  }
@@ -1213,7 +1472,8 @@ export class AgentBase {
1213
1472
  let needsInference = resumed.length > 0;
1214
1473
  if (!this.#recoveryChecked) {
1215
1474
  this.#recoveryChecked = true;
1216
- needsInference ||= this.#resumesInterruptedRun();
1475
+ if (await this.#resumesInterruptedRun())
1476
+ needsInference = true;
1217
1477
  }
1218
1478
  // Each cycle first drains the queues, then runs one inference. Steering injects at
1219
1479
  // every stop between responses and always outranks sends; sent messages inject
@@ -1232,7 +1492,7 @@ export class AgentBase {
1232
1492
  if (abort.signal.aborted) {
1233
1493
  const hasPendingWork = needsInference || this.#steering.length > 0 || this.#sends.length > 0;
1234
1494
  if (hasPendingWork)
1235
- this.#emit({ type: "done", state: "cancelled" });
1495
+ await this.#emit({ type: "done", state: "cancelled" });
1236
1496
  break;
1237
1497
  }
1238
1498
  let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering");
@@ -1251,8 +1511,19 @@ export class AgentBase {
1251
1511
  // either.
1252
1512
  if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
1253
1513
  continue;
1254
- await this.#enterStage("inference", this.#hooks.beforeInferenceTransact);
1255
- 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);
1256
1527
  const stream = session.run(this.#ctx, {
1257
1528
  context: {
1258
1529
  instructions,
@@ -1266,6 +1537,7 @@ export class AgentBase {
1266
1537
  // A cancelled or failed response measures nothing, so the conversation keeps
1267
1538
  // the last real measurement instead of forgetting how large it had become.
1268
1539
  const inference = {
1540
+ ...inferenceStart,
1269
1541
  state,
1270
1542
  tokens,
1271
1543
  ...(errorMessage === undefined ? {} : { errorMessage }),
@@ -1273,11 +1545,19 @@ export class AgentBase {
1273
1545
  const afterInferenceTransact = this.#hooks.afterInferenceTransact === undefined
1274
1546
  ? undefined
1275
1547
  : (hookCtx) => this.#hooks.afterInferenceTransact?.(hookCtx, inference);
1276
- if (tokens === undefined) {
1277
- 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
+ }
1278
1557
  }
1279
- else {
1280
- await this.#recordContextTokens(tokens.input + tokens.output, afterInferenceTransact);
1558
+ catch (error) {
1559
+ this.#inferenceId = completedInferenceId;
1560
+ throw error;
1281
1561
  }
1282
1562
  await this.#invokeHook(this.#hooks.afterInference, inference);
1283
1563
  if (content.length > 0) {
@@ -1299,10 +1579,7 @@ export class AgentBase {
1299
1579
  const calls = content.filter((block) => block.type === "tool_call" && block.server !== true);
1300
1580
  if (calls.length === 0)
1301
1581
  continue;
1302
- const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => ({
1303
- key: this.#toolKey(index, call.callId),
1304
- call,
1305
- })), false, abort.signal, abortPromise);
1582
+ const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => this.#newToolEntry(index, call)), false, abort.signal, abortPromise);
1306
1583
  if (closedDuringTools)
1307
1584
  break;
1308
1585
  needsInference = true;
@@ -1323,7 +1600,7 @@ export class AgentBase {
1323
1600
  }
1324
1601
  }
1325
1602
  catch (error) {
1326
- this.#emit({
1603
+ await this.#emit({
1327
1604
  type: "done",
1328
1605
  state: "error",
1329
1606
  kind: "internal_error",
@@ -1350,12 +1627,13 @@ export class AgentBase {
1350
1627
  * beginning of a block that will now never arrive is told to drop it. Only finished blocks
1351
1628
  * are persisted, so the conversation is intact and it is the view being corrected.
1352
1629
  */
1353
- #resumesInterruptedRun() {
1630
+ async #resumesInterruptedRun() {
1354
1631
  const owed = this.#lastRecordType === "user" ||
1355
1632
  this.#lastRecordType === "tool" ||
1356
1633
  this.#lastRecordType === "system";
1357
- if (owed && this.#inherited?.stage === "inference")
1358
- this.#emit({ type: "block_reset" });
1634
+ if (owed && this.#inherited?.stage === "inference") {
1635
+ await this.#emit({ type: "block_reset" });
1636
+ }
1359
1637
  return owed;
1360
1638
  }
1361
1639
  /** Load the durable state once. A failed load is not sticky: the next turn retries it. */
@@ -1375,7 +1653,7 @@ export class AgentBase {
1375
1653
  const previousTokens = this.#contextTokens;
1376
1654
  this.#contextTokens = tokens;
1377
1655
  try {
1378
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1656
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1379
1657
  const write = (writeCtx) => tokens === undefined
1380
1658
  ? this.#persistence.deleteValue(writeCtx, "context")
1381
1659
  : this.#persistence.writeValue(writeCtx, "context", { tokens });
@@ -1385,6 +1663,7 @@ export class AgentBase {
1385
1663
  }
1386
1664
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1387
1665
  await write(txCtx);
1666
+ await this.#recordPending(txCtx, "inference", true);
1388
1667
  await this.#withTransactionalContext(txCtx, transact);
1389
1668
  });
1390
1669
  });
@@ -1426,10 +1705,11 @@ export class AgentBase {
1426
1705
  throw new Error(result.message);
1427
1706
  }
1428
1707
  if (result.status === "completed") {
1429
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1708
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1430
1709
  // Physically delete the superseded records and write the replacement —
1431
1710
  // which keeps the messages that stay — in one atomic step.
1432
1711
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1712
+ await this.#deleteMessageIdentities(txCtx, await this.#persistence.load(txCtx));
1433
1713
  await this.#persistence.clearRecords(txCtx);
1434
1714
  await this.#persistence.append(txCtx, {
1435
1715
  type: "compaction",
@@ -1468,32 +1748,55 @@ export class AgentBase {
1468
1748
  if (owed.length === 0)
1469
1749
  return true;
1470
1750
  let settled = false;
1751
+ let staged = false;
1471
1752
  try {
1472
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1753
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1473
1754
  // A call the durable batch still holds belongs to the resume, which answers it
1474
1755
  // properly — and re-executes it when the tool is durable. Settling it here as
1475
1756
  // well would give the conversation two results for one call.
1476
1757
  const pending = await this.#persistence.readValues(lockCtx, "tool.");
1477
- const dispatched = new Set(pending.map(({ value }) => value.callId));
1478
- const results = owed
1479
- .filter((call) => !dispatched.has(call.callId))
1480
- .map((call) => toolFailure(call.callId, reason));
1481
- 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;
1482
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.
1773
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
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;
1483
1780
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1484
- for (const result of results) {
1781
+ for (const entry of entries) {
1782
+ const result = entry.committed;
1485
1783
  await this.#appendRecord(txCtx, { type: "tool", message: result });
1784
+ await this.#persistence.deleteValue(txCtx, entry.key);
1486
1785
  // A result the conversation records is a result the hook sees, however
1487
1786
  // little of a run produced it. A hook that fails here leaves the calls
1488
1787
  // unsettled, which is what lets a later attempt answer them properly.
1489
- 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);
1490
1790
  }
1791
+ await this.#recordPending(txCtx, "inference");
1491
1792
  });
1492
- this.#messages.push(...results);
1793
+ this.#messages.push(...entries.map(({ committed }) => committed));
1794
+ settled = true;
1493
1795
  });
1494
- settled = true;
1495
1796
  }
1496
1797
  catch {
1798
+ if (staged)
1799
+ this.#durableWorkBlocked = true;
1497
1800
  // The turn is already failing; a restart settles what this could not, as long as
1498
1801
  // nothing is written over the top of the call in the meantime.
1499
1802
  }
@@ -1505,6 +1808,15 @@ export class AgentBase {
1505
1808
  async #appendRecord(ctx, record) {
1506
1809
  await this.#persistence.append(ctx, record);
1507
1810
  }
1811
+ /** Remove deduplication identities for user records a history replacement is deleting. */
1812
+ async #deleteMessageIdentities(ctx, records) {
1813
+ for (const record of records) {
1814
+ if (record.type === "user") {
1815
+ await this.#persistence.deleteValue(ctx, `message.${record.id}`);
1816
+ this.#offeredMessageIds.delete(record.id);
1817
+ }
1818
+ }
1819
+ }
1508
1820
  /**
1509
1821
  * A transaction whose pending-state cache unwinds with it.
1510
1822
  */
@@ -1514,12 +1826,20 @@ export class AgentBase {
1514
1826
  // agent skip the write that actually records what it is doing.
1515
1827
  const pending = this.#pending;
1516
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;
1517
1833
  try {
1518
1834
  return await this.#persistence.transaction(ctx, work);
1519
1835
  }
1520
1836
  catch (error) {
1521
1837
  this.#pending = pending;
1522
1838
  this.#pendingWritten = written;
1839
+ this.#loopId = loopId;
1840
+ this.#turnId = turnId;
1841
+ this.#inferenceId = inferenceId;
1842
+ this.#settlementId = settlementId;
1523
1843
  throw error;
1524
1844
  }
1525
1845
  }
@@ -1538,7 +1858,7 @@ export class AgentBase {
1538
1858
  content: [{ type: "text", text: `The last turn failed: ${message}` }],
1539
1859
  };
1540
1860
  try {
1541
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1861
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1542
1862
  await this.#appendRecord(lockCtx, { type: "system", message: failure });
1543
1863
  this.#messages.push(failure);
1544
1864
  });
@@ -1561,7 +1881,7 @@ export class AgentBase {
1561
1881
  /** Filled in once the consumption has committed, and reported after the lock is released. */
1562
1882
  const accepted = [];
1563
1883
  let permissionChange;
1564
- const consumed = await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1884
+ const consumed = await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1565
1885
  if (queue.length === 0)
1566
1886
  return false;
1567
1887
  // The durable queue, not memory, decides what is left to consume after a restart.
@@ -1605,7 +1925,7 @@ export class AgentBase {
1605
1925
  }
1606
1926
  }
1607
1927
  // The mode the messages make effective, kept apart from the rest because it is the one
1608
- // 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
1609
1929
  // from it commits with the message that carried it.
1610
1930
  const modeChange = permissionMode === this.#permissionMode
1611
1931
  ? undefined
@@ -1698,6 +2018,7 @@ export class AgentBase {
1698
2018
  await this.#persistence.deleteValue(txCtx, entry.key);
1699
2019
  }
1700
2020
  if (reset) {
2021
+ await this.#deleteMessageIdentities(txCtx, await this.#persistence.load(txCtx));
1701
2022
  await this.#persistence.clearRecords(txCtx);
1702
2023
  // The erased conversation is what the measurement described.
1703
2024
  await this.#persistence.deleteValue(txCtx, "context");
@@ -1711,7 +2032,9 @@ export class AgentBase {
1711
2032
  for (const entry of batch) {
1712
2033
  await this.#appendRecord(txCtx, {
1713
2034
  type: "user",
2035
+ id: entry.id,
1714
2036
  message: entry.message,
2037
+ ...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
1715
2038
  });
1716
2039
  }
1717
2040
  if (changed) {
@@ -1726,7 +2049,7 @@ export class AgentBase {
1726
2049
  // Consuming a message is precisely the act that makes an inference owed, so
1727
2050
  // the two commit as one. A crash cannot land between them and leave a
1728
2051
  // message in the conversation that nothing remembers having to answer.
1729
- await this.#recordPending(txCtx, { stage: "inference" });
2052
+ await this.#recordPending(txCtx, "inference");
1730
2053
  // Last, so a hook writing its own account of the consumption sees a transaction
1731
2054
  // holding all of it. The mode comes before the messages: it is what they were
1732
2055
  // said under, and a listener recording them wants to know that first.
@@ -1735,13 +2058,23 @@ export class AgentBase {
1735
2058
  await this.#invokeTransactHook(txCtx, selection, this.#hooks.permissionModeChangedTransact, modeChange);
1736
2059
  }
1737
2060
  for (const entry of batch) {
1738
- await this.#invokeTransactHook(txCtx, selection, this.#hooks.messageAcceptedTransact, { kind, message: entry.message });
2061
+ await this.#invokeTransactHook(txCtx, selection, this.#hooks.messageAcceptedTransact, {
2062
+ id: entry.id,
2063
+ kind,
2064
+ message: entry.message,
2065
+ ...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
2066
+ });
1739
2067
  }
1740
2068
  });
1741
2069
  // Committed: from here the messages are part of the conversation, so what has to be
1742
2070
  // announced about them is decided now and reported once the lock is released.
1743
2071
  permissionChange = modeChange;
1744
- accepted.push(...batch.map((entry) => ({ kind, message: entry.message })));
2072
+ accepted.push(...batch.map((entry) => ({
2073
+ id: entry.id,
2074
+ kind,
2075
+ message: entry.message,
2076
+ ...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
2077
+ })));
1745
2078
  queue.splice(0, count);
1746
2079
  if (reset) {
1747
2080
  this.#messages = injected === undefined ? [] : [injected];
@@ -1794,7 +2127,7 @@ export class AgentBase {
1794
2127
  * not-yet-consumed queues. Consecutive block records reassemble into one assistant message.
1795
2128
  */
1796
2129
  async #loadHistory() {
1797
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
2130
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1798
2131
  const records = await this.#persistence.load(lockCtx);
1799
2132
  const last = records[records.length - 1];
1800
2133
  this.#lastRecordType = last?.type;
@@ -1811,7 +2144,17 @@ export class AgentBase {
1811
2144
  this.#contextTokens = measured?.tokens;
1812
2145
  const entry = (key, value) => {
1813
2146
  const envelope = value;
1814
- return { key, message: envelope.message, options: envelope.options ?? {} };
2147
+ if (!Value.Check(cuid2Schema, envelope.id)) {
2148
+ throw new Error(`The queued message under "${key}" has an invalid ID.`);
2149
+ }
2150
+ const metadata = ownAgentMessageMetadata(envelope.metadata);
2151
+ return {
2152
+ key,
2153
+ id: envelope.id,
2154
+ message: envelope.message,
2155
+ ...(metadata === undefined ? {} : { metadata }),
2156
+ options: envelope.options ?? {},
2157
+ };
1815
2158
  };
1816
2159
  this.#steering = steering.map(({ key, value }) => entry(key, value));
1817
2160
  this.#sends = sends.map(({ key, value }) => entry(key, value));
@@ -1833,10 +2176,7 @@ export class AgentBase {
1833
2176
  }
1834
2177
  this.#ctx = this.#deriveCtx();
1835
2178
  }
1836
- this.#pendingTools = pendingTools.map(({ key, value }) => ({
1837
- key,
1838
- call: value,
1839
- }));
2179
+ this.#pendingTools = pendingTools.map(({ key, value }) => this.#restoreToolEntry(key, value));
1840
2180
  this.#pendingToolsUndispatched = false;
1841
2181
  if (this.#pendingTools.length === 0) {
1842
2182
  // A crash between the response's last block and the batch commit leaves calls
@@ -1846,10 +2186,7 @@ export class AgentBase {
1846
2186
  // dispatched.
1847
2187
  const owed = this.#unansweredCalls(restored);
1848
2188
  if (owed.length > 0) {
1849
- this.#pendingTools = owed.map((call, index) => ({
1850
- key: this.#toolKey(index, call.callId),
1851
- call,
1852
- }));
2189
+ this.#pendingTools = owed.map((call, index) => this.#newToolEntry(index, call));
1853
2190
  this.#pendingToolsUndispatched = true;
1854
2191
  }
1855
2192
  }
@@ -1867,18 +2204,18 @@ export class AgentBase {
1867
2204
  */
1868
2205
  async #runToolBatch(entries, resume, signal, abortPromise) {
1869
2206
  if (!resume) {
1870
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
2207
+ await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
1871
2208
  for (const entry of entries) {
1872
- await this.#persistence.writeValue(txCtx, entry.key, entry.call);
2209
+ await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry(entry));
1873
2210
  }
1874
2211
  // The batch and the stage that describes it commit together. A crash can
1875
2212
  // then never find calls owed with no record of a run owing them, nor a run
1876
2213
  // recorded as running tools that were never written.
1877
- await this.#recordPending(txCtx, { stage: "tools" });
2214
+ await this.#recordPending(txCtx, "tools");
1878
2215
  // Last, so a hook noting a call about to happen sees a transaction holding
1879
2216
  // the whole batch it belongs to.
1880
2217
  for (const entry of entries) {
1881
- 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);
1882
2219
  }
1883
2220
  }));
1884
2221
  }
@@ -1899,32 +2236,46 @@ export class AgentBase {
1899
2236
  if (commitFailed)
1900
2237
  return;
1901
2238
  try {
1902
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
2239
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1903
2240
  while (committed < entries.length) {
1904
2241
  const entry = entries[committed];
1905
- const result = results[committed];
1906
- if (entry === undefined || result === undefined)
2242
+ const proposed = results[committed];
2243
+ if (entry === undefined || proposed === undefined)
1907
2244
  return;
2245
+ let winner = proposed;
1908
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;
1909
2260
  await this.#appendRecord(txCtx, {
1910
2261
  type: "tool",
1911
2262
  message: result,
1912
2263
  });
1913
- // The call is answered, so what was kept only to let it be retried
1914
- // goes with it. What the tool itself wrote under its own call scope
1915
- // stays: that is the tool's state, not the batch's bookkeeping, and
1916
- // 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.
1917
2266
  await this.#persistence.deleteValue(txCtx, entry.key);
1918
- 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);
1919
2270
  });
1920
- this.#messages.push(result);
2271
+ this.#messages.push(winner);
1921
2272
  committed += 1;
1922
2273
  }
1923
2274
  // The batch is fully answered, so its results are what the model is owed a
1924
2275
  // response to. Recording that here means a crash between the last result and
1925
2276
  // the next request resumes as an inference rather than as a finished batch.
1926
2277
  if (committed === entries.length) {
1927
- await this.#recordPending(lockCtx, { stage: "inference" });
2278
+ await this.#recordPending(lockCtx, "inference");
1928
2279
  }
1929
2280
  });
1930
2281
  }
@@ -1936,12 +2287,15 @@ export class AgentBase {
1936
2287
  this.#toolsRunning += 1;
1937
2288
  const batch = Promise.all(entries.map(async (entry, index) => {
1938
2289
  let outcome;
1939
- 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))) {
1940
2294
  outcome = toolFailure(entry.call.callId, "The tool call was interrupted by a restart and was not retried.");
1941
2295
  }
1942
2296
  else {
1943
2297
  const toolLifetime = AbortSignal.any([signal, this.#closeController.signal]);
1944
- const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry.call);
2298
+ const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry);
1945
2299
  running.push(execution);
1946
2300
  outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
1947
2301
  }
@@ -2008,29 +2362,77 @@ export class AgentBase {
2008
2362
  return [];
2009
2363
  return last.content.filter((block) => block.type === "tool_call" && block.server !== true);
2010
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
+ }
2011
2409
  /** Sorted by position in the batch; only one batch is ever pending at a time. */
2012
- #toolKey(index, callId) {
2013
- 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}`;
2014
2416
  }
2015
2417
  /**
2016
- * The scope one call owns: state persists under its own call ID, never in another call's
2017
- * scope, and the task context ends where the call was made. The execution and all four tool
2018
- * 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.
2019
2421
  */
2020
- #callScoped(ctx, callId) {
2021
- 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));
2022
2424
  }
2023
2425
  /**
2024
2426
  * Call a tool hook that writes inside a transaction of its own call's. The lifetime ends with
2025
2427
  * the callback, so a context kept afterwards cannot outlive the transaction it belongs to.
2026
2428
  * Its failure is not contained: it rolls that transaction back.
2027
2429
  */
2028
- async #invokeToolTransactHook(txCtx, callId, hook, argument) {
2430
+ async #invokeToolTransactHook(txCtx, id, providerCallId, hook, argument) {
2029
2431
  if (hook === undefined)
2030
2432
  return;
2031
2433
  const lifetime = new AbortController();
2032
2434
  try {
2033
- await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal), callId), argument);
2435
+ await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal), id, providerCallId), argument);
2034
2436
  }
2035
2437
  finally {
2036
2438
  lifetime.abort();
@@ -2041,7 +2443,8 @@ export class AgentBase {
2041
2443
  * The context carries the turn's abort signal as its lifetime, so a running tool can
2042
2444
  * observe cancellation and stop its own work.
2043
2445
  */
2044
- async #executeToolCall(ctx, call) {
2446
+ async #executeToolCall(ctx, entry) {
2447
+ const { call } = entry;
2045
2448
  const failure = (text) => ({
2046
2449
  role: "tool",
2047
2450
  callId: call.callId,
@@ -2065,13 +2468,90 @@ export class AgentBase {
2065
2468
  if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
2066
2469
  return failure(`The arguments for "${call.name}" did not match its schema.`);
2067
2470
  }
2068
- 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);
2069
2476
  // From here the call is one the two tool hooks bracket: a tool that exists, a call that
2070
2477
  // finished, and arguments its schema accepts. A call refused before that reaches neither
2071
2478
  // hook, because there is nothing yet to decide about or to report.
2072
2479
  let ran = tool;
2073
2480
  let ranArguments = args;
2074
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
+ };
2075
2555
  try {
2076
2556
  const decision = await this.#hooks.beforeToolCall?.(callCtx, {
2077
2557
  callId: call.callId,
@@ -2104,38 +2584,64 @@ export class AgentBase {
2104
2584
  const runCtx = decision?.permissionMode === undefined
2105
2585
  ? callCtx
2106
2586
  : withAgentPermissionMode(callCtx, decision.permissionMode);
2107
- const result = await ran.execute(runCtx, ranArguments);
2108
- if (!Value.Check(ran.returnType, result)) {
2109
- 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);
2110
2614
  }
2111
- outcome = {
2112
- callId: call.callId,
2113
- tool: ran,
2114
- arguments: ranArguments,
2115
- content: [...ran.toLLM(result)],
2116
- isError: ran.isError?.(result) === true,
2117
- result,
2118
- };
2119
2615
  }
2120
2616
  }
2121
2617
  catch (error) {
2122
- 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",
2123
2637
  callId: call.callId,
2124
- tool: ran,
2125
- arguments: ranArguments,
2126
- content: [
2127
- { type: "text", text: error instanceof Error ? error.message : String(error) },
2128
- ],
2129
- isError: true,
2638
+ content: outcome.content,
2639
+ ...(outcome.isError ? { isError: true } : {}),
2130
2640
  };
2131
2641
  }
2132
- await this.#invokeHookOn(callCtx, this.#hooks.afterToolCall, outcome);
2133
- return {
2134
- role: "tool",
2135
- callId: call.callId,
2136
- content: outcome.content,
2137
- ...(outcome.isError ? { isError: true } : {}),
2138
- };
2642
+ finally {
2643
+ callLifetime.abort();
2644
+ }
2139
2645
  }
2140
2646
  /**
2141
2647
  * A key that sorts after every entry the queue already holds. The order comes from the store
@@ -2171,7 +2677,7 @@ export class AgentBase {
2171
2677
  const persist = async (event) => {
2172
2678
  if (event === undefined)
2173
2679
  return;
2174
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
2680
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
2175
2681
  if (this.#hooks.onEventTransact === undefined) {
2176
2682
  await this.#appendRecord(lockCtx, { type: "block", block: event.block });
2177
2683
  }
@@ -2196,7 +2702,7 @@ export class AgentBase {
2196
2702
  const next = await Promise.race([iterator.next(), abortPromise]);
2197
2703
  if (next === ABORTED) {
2198
2704
  // Drop the unfinished block and end the turn.
2199
- this.#emit({ type: "done", state: "cancelled" });
2705
+ await this.#emit({ type: "done", state: "cancelled" });
2200
2706
  return { content: persisted, state: "cancelled" };
2201
2707
  }
2202
2708
  if (next.done === true) {
@@ -2204,7 +2710,7 @@ export class AgentBase {
2204
2710
  break;
2205
2711
  }
2206
2712
  const event = next.value;
2207
- this.#emit(event);
2713
+ await this.#emit(event);
2208
2714
  switch (event.type) {
2209
2715
  case "text_start":
2210
2716
  content.push({ type: "text", text: "" });
@@ -2355,9 +2861,9 @@ export class AgentBase {
2355
2861
  return this.#session;
2356
2862
  }
2357
2863
  /** Report one stream event to the hooks. Hooks observe the stream; they never fail a run. */
2358
- #emit(event) {
2864
+ async #emit(event) {
2359
2865
  try {
2360
- this.#hooks.onEvent?.(this.#ctx, event);
2866
+ await this.#hooks.onEvent?.(this.#ctx, event);
2361
2867
  }
2362
2868
  catch {
2363
2869
  // Hooks observe the stream; they never fail a run.