@slopus/happy-agent-base 0.0.17 → 0.0.18

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 (54) hide show
  1. package/README.md +20 -0
  2. package/dist/Agent.d.ts +3 -1
  3. package/dist/Agent.d.ts.map +1 -1
  4. package/dist/Agent.js +5 -2
  5. package/dist/Agent.js.map +1 -1
  6. package/dist/AgentBase.d.ts +13 -7
  7. package/dist/AgentBase.d.ts.map +1 -1
  8. package/dist/AgentBase.js +291 -149
  9. package/dist/AgentBase.js.map +1 -1
  10. package/dist/AgentBaseHooks.d.ts +21 -2
  11. package/dist/AgentBaseHooks.d.ts.map +1 -1
  12. package/dist/AgentDatabase.d.ts.map +1 -1
  13. package/dist/AgentDatabase.js +27 -14
  14. package/dist/AgentDatabase.js.map +1 -1
  15. package/dist/AgentDatabaseConnection.d.ts +39 -0
  16. package/dist/AgentDatabaseConnection.d.ts.map +1 -0
  17. package/dist/AgentDatabaseConnection.js +199 -0
  18. package/dist/AgentDatabaseConnection.js.map +1 -0
  19. package/dist/AgentModule.d.ts +7 -1
  20. package/dist/AgentModule.d.ts.map +1 -1
  21. package/dist/AgentPersistence.d.ts +4 -4
  22. package/dist/AgentRef.d.ts +1 -1
  23. package/dist/AgentRef.js +1 -1
  24. package/dist/AgentSpanAttributes.d.ts +11 -0
  25. package/dist/AgentSpanAttributes.d.ts.map +1 -0
  26. package/dist/AgentSpanAttributes.js +11 -0
  27. package/dist/AgentSpanAttributes.js.map +1 -0
  28. package/dist/AgentStorage.d.ts.map +1 -1
  29. package/dist/AgentStorage.js +2 -0
  30. package/dist/AgentStorage.js.map +1 -1
  31. package/dist/AgentSystemLocal.d.ts.map +1 -1
  32. package/dist/AgentSystemLocal.js +53 -30
  33. package/dist/AgentSystemLocal.js.map +1 -1
  34. package/dist/inTx.d.ts +0 -7
  35. package/dist/inTx.d.ts.map +1 -1
  36. package/dist/inTx.js +9 -16
  37. package/dist/inTx.js.map +1 -1
  38. package/dist/index.d.ts +5 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/openAgentPGliteDatabase.d.ts +10 -0
  43. package/dist/openAgentPGliteDatabase.d.ts.map +1 -0
  44. package/dist/openAgentPGliteDatabase.js +17 -0
  45. package/dist/openAgentPGliteDatabase.js.map +1 -0
  46. package/dist/openAgentPostgresDatabase.d.ts +13 -0
  47. package/dist/openAgentPostgresDatabase.d.ts.map +1 -0
  48. package/dist/openAgentPostgresDatabase.js +10 -0
  49. package/dist/openAgentPostgresDatabase.js.map +1 -0
  50. package/dist/openAgentSQLiteDatabase.d.ts +6 -0
  51. package/dist/openAgentSQLiteDatabase.d.ts.map +1 -0
  52. package/dist/openAgentSQLiteDatabase.js +48 -0
  53. package/dist/openAgentSQLiteDatabase.js.map +1 -0
  54. package/package.json +6 -4
package/dist/AgentBase.js CHANGED
@@ -3,15 +3,17 @@ import { createId } from "@paralleldrive/cuid2";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { Value } from "@sinclair/typebox/value";
6
- import { afterCommit, asyncLock, createContextNamespace, detach, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
6
+ import { afterCommit, createContextNamespace, detach, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
7
7
  import { agentDatabase, agentKV, agentStorageTransaction, withAgentContext, withAgentDatabase, withAgentHistoryKV, withAgentKV, withAgentPermissionMode, withAgentRunKV, } from "./AgentContexts.js";
8
8
  import { agentConfig, ownAgentConfig, withAgentConfig } from "./AgentConfig.js";
9
+ import { outsideAgentDatabaseOperation } from "./AgentDatabaseConnection.js";
9
10
  import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
10
11
  import { AgentKV } from "./AgentKV.js";
11
12
  import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
12
13
  import { cuid2Schema, ownAgentMessageMetadata, ownAgentMetadata, } from "./AgentMetadata.js";
13
14
  import { DEFAULT_AGENT_PERMISSION_MODE, isAgentPermissionMode, } from "./AgentPermissionMode.js";
14
15
  import { AgentProviders } from "./AgentProviders.js";
16
+ import { setAgentSpanAttributes } from "./AgentSpanAttributes.js";
15
17
  /** Race winner when an abort interrupts a wait on the stream or a running tool. */
16
18
  const ABORTED = Symbol("aborted");
17
19
  /**
@@ -34,8 +36,6 @@ const insideTurn = createContextNamespace("agentInsideTurn", [], {
34
36
  * its own and outlives whatever happened to start it.
35
37
  */
36
38
  const insideLoops = new AsyncLocalStorage();
37
- /** Persistence locks held by the current asynchronous call chain, independent of Context. */
38
- const insidePersistenceLocks = new AsyncLocalStorage();
39
39
  /**
40
40
  * How long a close asked for from inside the agent's own run loop waits for the shutdown before
41
41
  * telling its caller it cannot be waited for. Long enough that a caller which has already let go
@@ -76,10 +76,10 @@ const storedToolResultSchema = Type.Object({
76
76
  *
77
77
  * ## Serialization
78
78
  *
79
- * One lock serializes every persistence operation together with its in-memory effect, so storage
80
- * order always matches history order and a load never overlaps an append. Anything that decides
81
- * from durable state resolves that state inside the lock rather than capturing it beforehand; a
82
- * reference taken before a wait can belong to a history that has since been replaced.
79
+ * Database transactions and ordered keys keep persistence changes aligned with their in-memory
80
+ * publication. Anything that decides from durable state resolves that state inside the committing
81
+ * step rather than capturing it beforehand; a reference taken before a wait can belong to a
82
+ * history that has since been replaced.
83
83
  *
84
84
  * ## Accepting a message
85
85
  *
@@ -132,8 +132,8 @@ const storedToolResultSchema = Type.Object({
132
132
  *
133
133
  * An incompatible provider or model change resets the conversation; a compatible one keeps it.
134
134
  * Either way the change lands on one side or the other, never the old history under the new
135
- * model. `modelChanged` runs inside the lock and is lent a store bound to that hold — a
136
- * capability released when the hook returns, so it cannot be retained to bypass the lock later.
135
+ * model. `modelChanged` runs inside the transaction and is lent a store bound to it — a
136
+ * capability released when the hook returns, so it cannot be retained to write after commit.
137
137
  * A failing handoff rejects an incompatible switch outright rather than costing the history.
138
138
  *
139
139
  * ## Permission modes
@@ -256,12 +256,6 @@ export class AgentBase {
256
256
  #permissionMode;
257
257
  /** The single set of hooks the run is observed by and its configuration extended from. */
258
258
  #hooks;
259
- /**
260
- * Serializes every persistence operation together with its in-memory effect, so storage
261
- * order always matches history order and a load never overlaps an append. The lock belongs
262
- * to the store, so an owner inspecting the same store sees only whole steps.
263
- */
264
- #persistenceLock;
265
259
  /** The session-scoped key-value store carried on every context the agent derives. */
266
260
  #kv;
267
261
  /** Durable state tied to the current history and invalidated when that history is replaced. */
@@ -348,6 +342,14 @@ export class AgentBase {
348
342
  #inherited;
349
343
  /** Whether that inherited record has been read; it can only be read before it is overwritten. */
350
344
  #inheritedRead = false;
345
+ /**
346
+ * Whether the activation hook is still owed an announcement for inherited work: the store
347
+ * already said the agent was working when this process first looked, and no run of this
348
+ * process has announced taking that work up yet.
349
+ */
350
+ #restoreActivationOwed = false;
351
+ /** Whether this process has announced the activation of the current active period. */
352
+ #activationAnnounced = false;
351
353
  /**
352
354
  * The compaction that has been asked for and not carried out yet, together with the promise
353
355
  * every caller waiting for it shares. Requesting one while it is pending joins that promise
@@ -382,6 +384,8 @@ export class AgentBase {
382
384
  #closing;
383
385
  /** Operations accepted from a caller and not finished yet; a close waits for every one. */
384
386
  #admitted = new Set();
387
+ /** Queue acceptances the run loop must publish before it may decide its queues are empty. */
388
+ #messageAdmissions = new Set();
385
389
  /**
386
390
  * Work from an earlier response that is still unwinding: a provider stream that has not
387
391
  * finished closing, or a tool that was settled in the conversation by an abort and is still
@@ -415,10 +419,16 @@ export class AgentBase {
415
419
  * they are needed by the first turn and by nothing before it, so an owner resuming a hundred
416
420
  * identities at startup pays for a hundred small reads rather than a hundred transcripts.
417
421
  * The rest loads on the way into the turn that actually needs it.
422
+ *
423
+ * The read normally runs on the agent's own context. A caller resolving the agent from
424
+ * inside an open storage transaction passes `loadCtx` to route this one read through that
425
+ * transaction's connection instead, because a single-connection driver cannot serve a read
426
+ * beside the transaction it is holding open. The agent's own lifetime context is detached
427
+ * either way and never carries the caller's transaction.
418
428
  */
419
- static async load(ctx, options) {
429
+ static async load(ctx, options, loadCtx) {
420
430
  const agent = new AgentBase(ctx, options);
421
- await agent.#loadPendingState();
431
+ await agent.#loadPendingState(loadCtx ?? agent.#ctx);
422
432
  return agent;
423
433
  }
424
434
  /**
@@ -440,14 +450,18 @@ export class AgentBase {
440
450
  /**
441
451
  * Read the outstanding work the store already holds, before this instance has written any of
442
452
  * its own. It is both what `active` answers from and what an interrupted run is recognized
443
- * by, so reading it here leaves the later stage writes nothing to learn from the store.
444
- */
445
- async #loadPendingState() {
446
- await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
447
- await this.#loadConfig(lockCtx);
448
- const stored = await agentBasePendingStateOf(lockCtx, this.#persistence);
453
+ * by, so reading it here leaves the later stage writes nothing to learn from the store. The
454
+ * context is the caller's choice: the agent's own for an ordinary load, or the resolving
455
+ * transaction's when the read has to ride that transaction's connection.
456
+ */
457
+ async #loadPendingState(ctx) {
458
+ await this.#runPersistenceStep(ctx, async (operationCtx) => {
459
+ await this.#loadConfig(operationCtx);
460
+ const stored = await agentBasePendingStateOf(operationCtx, this.#persistence);
449
461
  this.#inherited = stored;
450
462
  this.#inheritedRead = true;
463
+ this.#restoreActivationOwed =
464
+ stored !== undefined && this.#hooks.afterAgentActivatedTransact !== undefined;
451
465
  this.#pending = stored;
452
466
  this.#loopId = stored?.loopId;
453
467
  this.#turnId = stored?.turnId;
@@ -477,7 +491,6 @@ export class AgentBase {
477
491
  this.#providers = options.providers;
478
492
  this.#providerId = options.provider;
479
493
  this.#persistence = options.persistence;
480
- this.#persistenceLock = asyncLock({ reentry: "block" });
481
494
  this.#hooks = options.hooks ?? {};
482
495
  this.state = {
483
496
  instructions: options.initialState?.instructions ?? "",
@@ -553,6 +566,27 @@ export class AgentBase {
553
566
  permissionMode: this.#permissionMode,
554
567
  };
555
568
  }
569
+ /**
570
+ * Open agent work with the selection that governs it, so every operation in a trace can be
571
+ * attributed without recording prompts, arguments, or provider response content.
572
+ */
573
+ #span(ctx, name, attributes, work) {
574
+ const selection = this.#selection();
575
+ return Promise.resolve(ctx.span(name, (spanCtx) => {
576
+ setAgentSpanAttributes(spanCtx, {
577
+ "agent.id": selection.id,
578
+ "agent.provider": selection.provider,
579
+ "agent.permission_mode": selection.permissionMode,
580
+ ...(selection.model === undefined ? {} : { "agent.model": selection.model }),
581
+ ...(selection.effort === undefined ? {} : { "agent.effort": selection.effort }),
582
+ ...(selection.serviceTier === undefined
583
+ ? {}
584
+ : { "agent.service_tier": selection.serviceTier }),
585
+ ...attributes,
586
+ });
587
+ return work(spanCtx);
588
+ }));
589
+ }
556
590
  /**
557
591
  * Whether the agent has anything left to do. This is the only thing about an agent's state
558
592
  * anyone outside it may read: the queues and the stage behind this answer are the run's own
@@ -589,7 +623,7 @@ export class AgentBase {
589
623
  };
590
624
  }
591
625
  /**
592
- * Record the stage the run has reached, taking the store lock when not already inside it.
626
+ * Record the stage the run has reached through the store's transaction boundary.
593
627
  *
594
628
  * The first of these also reads what the store already held, before overwriting it. That
595
629
  * reading is the only chance to see it: from this point the record says what this instance
@@ -599,27 +633,44 @@ export class AgentBase {
599
633
  async #enterStage(ctx, stage, transact) {
600
634
  const pending = this.#pendingState(stage);
601
635
  if (transact === undefined &&
636
+ !this.#restoreActivationOwed &&
602
637
  deterministicStringify(pending) === this.#pendingWritten &&
603
638
  this.#inheritedRead) {
604
639
  return undefined;
605
640
  }
606
641
  try {
607
- return await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
642
+ return await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
608
643
  if (!this.#inheritedRead) {
609
644
  this.#inheritedRead = true;
610
645
  this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
646
+ this.#restoreActivationOwed =
647
+ this.#inherited !== undefined &&
648
+ !this.#activationAnnounced &&
649
+ this.#hooks.afterAgentActivatedTransact !== undefined;
611
650
  }
612
- if (transact === undefined) {
651
+ const announceRestore = this.#restoreActivationOwed;
652
+ if (transact === undefined && !announceRestore) {
613
653
  await this.#recordPending(lockCtx, stage);
614
654
  return undefined;
615
655
  }
616
- return await this.#recordTransaction(lockCtx, async (txCtx) => {
656
+ const result = await this.#recordTransaction(lockCtx, async (txCtx) => {
617
657
  // The state is staged first. The callback then writes against this exact
618
658
  // transaction, so neither its conclusion nor the state it observed can land
619
659
  // without the other.
620
660
  await this.#recordPending(txCtx, stage, true);
621
- return await this.#withTransactionalContext(txCtx, transact);
661
+ // Taking up inherited work is what reactivates the agent after a restart,
662
+ // so the announcement commits with the resumed run's own stage record.
663
+ if (announceRestore)
664
+ await this.#announceActivation(txCtx, true);
665
+ return transact === undefined
666
+ ? undefined
667
+ : await this.#withTransactionalContext(txCtx, transact);
622
668
  });
669
+ if (announceRestore) {
670
+ this.#restoreActivationOwed = false;
671
+ this.#activationAnnounced = true;
672
+ }
673
+ return result;
623
674
  });
624
675
  }
625
676
  catch (error) {
@@ -640,6 +691,41 @@ export class AgentBase {
640
691
  this.#pending = undefined;
641
692
  this.#pendingWritten = undefined;
642
693
  }
694
+ /**
695
+ * Tell the activation hook the agent stopped being settled, inside the very transaction that
696
+ * records the work it now owes. A failure propagates and rolls that transaction back, so a
697
+ * module never concludes the agent woke up from a wake-up that never became durable.
698
+ */
699
+ async #announceActivation(txCtx, restored) {
700
+ const hook = this.#hooks.afterAgentActivatedTransact;
701
+ if (hook === undefined)
702
+ return;
703
+ await this.#withTransactionalContext(txCtx, (hookCtx) => hook(this.#workContext(hookCtx), { restored }));
704
+ }
705
+ /**
706
+ * Record that scheduled work made an inference owed, and decide whether this transaction is
707
+ * the one that woke a settled agent. The decision is the store's own absent-only insert of
708
+ * the pending record, so transactions racing to wake the same agent are arbitrated by the
709
+ * database rather than by a read another writer could make stale: exactly one creates the
710
+ * record and activates, and the others find it already present. A record that is present
711
+ * but unreadable counts as no pending state, as it does everywhere else.
712
+ */
713
+ async #claimPendingWork(txCtx) {
714
+ const pending = this.#pendingState("inference");
715
+ const serialized = deterministicStringify(pending);
716
+ // This instance already recorded the current run, so the work is already owed and
717
+ // nothing here can be an activation.
718
+ if (this.#pendingWritten === serialized)
719
+ return false;
720
+ let created = await this.#persistence.writeValueIfAbsent(txCtx, AGENT_BASE_PENDING_KEY, pending);
721
+ if (!created) {
722
+ created = (await agentBasePendingStateOf(txCtx, this.#persistence)) === undefined;
723
+ await this.#persistence.writeValue(txCtx, AGENT_BASE_PENDING_KEY, pending);
724
+ }
725
+ this.#pending = pending;
726
+ this.#pendingWritten = serialized;
727
+ return created;
728
+ }
643
729
  /**
644
730
  * Queue a user message that injects as soon as the current assistant response and its tool
645
731
  * batch finish; steering always takes precedence over sent messages. Returns once the message
@@ -671,34 +757,37 @@ export class AgentBase {
671
757
  throw new Error("The agent metadata is not valid.");
672
758
  if (this.#closed)
673
759
  throw new Error("The agent has been closed.");
674
- if (insideTurn.get(ctx).includes(this.id) ||
675
- this.#insideOwnLoop() ||
676
- this.#insideOwnPersistenceLock()) {
760
+ if (insideTurn.get(ctx).includes(this.id) || this.#insideOwnLoop()) {
677
761
  throw new Error("Updating metadata from inside this agent's current operation would wait for " +
678
762
  "that same operation to finish. Update it after the hook or tool returns.");
679
763
  }
680
764
  let change;
681
765
  let next;
682
- await this.#runInPersistenceLock(ctx, async (lockCtx) => {
683
- const previousMetadata = ownAgentMetadata(this.#config.metadata ?? {});
684
- const metadata = ownAgentMetadata({ ...previousMetadata, ...ownedUpdate });
685
- if (previousMetadata === undefined || metadata === undefined) {
686
- throw new Error("The agent metadata is not valid.");
687
- }
688
- next = ownAgentConfig({ ...this.#config, metadata });
689
- change = {
690
- agentId: this.id,
691
- previousMetadata,
692
- update: ownedUpdate,
693
- metadata,
694
- };
766
+ await this.#runPersistenceStep(ctx, async (lockCtx) => {
695
767
  await this.#persistence.transaction(lockCtx, async (txCtx) => {
768
+ const stored = await this.#persistence.readValues(txCtx, "agentConfig");
769
+ const exact = stored.find(({ key }) => key === "agentConfig")?.value;
770
+ const current = exact === undefined ? this.#config : ownAgentConfig(exact);
771
+ const previousMetadata = ownAgentMetadata(current.metadata ?? {});
772
+ const metadata = ownAgentMetadata({ ...previousMetadata, ...ownedUpdate });
773
+ if (previousMetadata === undefined || metadata === undefined) {
774
+ throw new Error("The agent metadata is not valid.");
775
+ }
776
+ next = ownAgentConfig({ ...current, metadata });
777
+ change = {
778
+ agentId: this.id,
779
+ previousMetadata,
780
+ update: ownedUpdate,
781
+ metadata,
782
+ };
696
783
  await this.#persistence.writeValue(txCtx, "agentConfig", next);
697
784
  await this.#withTransactionalContext(withAgentConfig(txCtx, next), async (hookCtx) => await this.#hooks.metadataChangedTransact?.(this.#hookContext(hookCtx), change));
785
+ afterCommit(txCtx, () => {
786
+ this.#config = next;
787
+ this.#baseCtx = withAgentConfig(this.#baseCtx, next);
788
+ this.#ctx = this.#deriveCtx();
789
+ });
698
790
  });
699
- this.#config = next;
700
- this.#baseCtx = withAgentConfig(this.#baseCtx, next);
701
- this.#ctx = this.#deriveCtx();
702
791
  });
703
792
  await this.#invokeHookOn(this.#hookContext(withAgentConfig(ctx, next)), this.#hooks.metadataChanged, change);
704
793
  }
@@ -712,9 +801,6 @@ export class AgentBase {
712
801
  if (outerTransaction?.lifetime.aborted === true) {
713
802
  throw new Error("The agent storage transaction carried by this context has ended.");
714
803
  }
715
- if (outerTransaction !== undefined && this.#insideOwnPersistenceLock()) {
716
- throw new Error("Agent message delivery cannot reenter its own persistence operation inside a transaction.");
717
- }
718
804
  const { await: requestedWait, id = createId(), metadata: suppliedMetadata, ...settings } = options ?? {};
719
805
  if (!Value.Check(cuid2Schema, id)) {
720
806
  throw new Error("The message ID must be a cuid2 identity.");
@@ -796,30 +882,20 @@ export class AgentBase {
796
882
  #insideOwnLoop() {
797
883
  return insideLoops.getStore()?.includes(this.id) === true;
798
884
  }
799
- /** Whether this call chain already holds this agent's persistence lock. */
800
- #insideOwnPersistenceLock() {
801
- return insidePersistenceLocks.getStore()?.includes(this.id) === true;
802
- }
803
885
  /** Live agent commands cannot be staged or undone by an enclosing database transaction. */
804
886
  #assertOutsideStorageTransaction(ctx, operation) {
805
887
  if (agentStorageTransaction(ctx) !== undefined) {
806
888
  throw new Error(`Agent ${operation} cannot run from inside an outer storage transaction.`);
807
889
  }
808
890
  }
809
- /** Hold the persistence lock while marking it independently of the caller's Context. */
810
- async #runInPersistenceLock(ctx, work) {
811
- return await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
812
- const held = insidePersistenceLocks.getStore() ?? [];
813
- return await insidePersistenceLocks.run([...held, this.id], async () => {
814
- return await work(lockCtx);
815
- });
816
- });
891
+ /** Run one persistence step; its database statements and transactions own consistency. */
892
+ async #runPersistenceStep(ctx, work) {
893
+ return await work(ctx);
817
894
  }
818
895
  /**
819
- * Accept a batch of messages as one durable step. Every message is written under the same
820
- * hold of the persistence lock and inside one transaction, so a caller arriving while a
821
- * batch is being written lands after the whole batch rather than in the middle of it, and a
822
- * failure admits none of them.
896
+ * Accept a batch of messages as one durable transaction, so a caller arriving while it is
897
+ * being written lands after the whole batch rather than in the middle of it, and a failure
898
+ * admits none of them.
823
899
  */
824
900
  async #enqueue(ctx, batch) {
825
901
  if (batch.length === 0)
@@ -832,19 +908,22 @@ export class AgentBase {
832
908
  ? this.#enqueueIndependently(ctx, batch)
833
909
  : this.#enqueueInTransaction(ctx, batch);
834
910
  this.#admitted.add(admitted);
911
+ this.#messageAdmissions.add(admitted);
835
912
  try {
836
913
  return await admitted;
837
914
  }
838
915
  finally {
839
916
  this.#admitted.delete(admitted);
917
+ this.#messageAdmissions.delete(admitted);
840
918
  }
841
919
  }
842
- /** Accept a batch under the persistence lock, in a transaction of the acceptance's own. */
920
+ /** Accept a batch in a transaction of the acceptance's own. */
843
921
  async #enqueueIndependently(ctx, batch) {
844
- return await this.#runInPersistenceLock(ctx, async (lockCtx) => {
922
+ return await this.#runPersistenceStep(ctx, async (lockCtx) => {
845
923
  const accepted = [];
846
924
  const results = [];
847
925
  await this.#recordTransaction(lockCtx, async (txCtx) => {
926
+ let activated = false;
848
927
  for (const request of batch) {
849
928
  const identityKey = `message.${request.id}`;
850
929
  if (!(await this.#persistence.writeValueIfAbsent(txCtx, identityKey, true))) {
@@ -874,36 +953,41 @@ export class AgentBase {
874
953
  // Accepting a message is what makes the work owed: the same transaction that
875
954
  // admits it records that the agent owes an answer, so a process that dies right
876
955
  // here is discovered still owing it rather than looking idle over a full queue.
877
- await this.#recordPending(txCtx, "inference");
878
- });
879
- for (const { key, request } of accepted) {
880
- // The queue is resolved inside the lock: a history load running just before this
881
- // one replaces the queue arrays wholesale, and a reference taken before the wait
882
- // would push the message into an array nobody reads again.
883
- const queue = request.kind === "steering" ? this.#steering : this.#sends;
884
- queue.push({
885
- key,
886
- id: request.id,
887
- message: request.message,
888
- ...(request.metadata === undefined ? {} : { metadata: request.metadata }),
889
- options: request.options,
956
+ // Creating that record where none existed is what makes a settled agent active,
957
+ // and the activation hook commits with the very transaction that admits the
958
+ // message.
959
+ if (await this.#claimPendingWork(txCtx)) {
960
+ await this.#announceActivation(txCtx, false);
961
+ activated = true;
962
+ }
963
+ afterCommit(txCtx, () => {
964
+ if (activated) {
965
+ this.#activationAnnounced = true;
966
+ this.#restoreActivationOwed = false;
967
+ }
968
+ for (const { key, request } of accepted) {
969
+ const queue = request.kind === "steering" ? this.#steering : this.#sends;
970
+ queue.push({
971
+ key,
972
+ id: request.id,
973
+ message: request.message,
974
+ ...(request.metadata === undefined
975
+ ? {}
976
+ : { metadata: request.metadata }),
977
+ options: request.options,
978
+ });
979
+ }
980
+ this.#turnRequested = true;
981
+ this.#startRun();
890
982
  });
891
- }
892
- if (accepted.length > 0) {
893
- this.#turnRequested = true;
894
- this.#startRun();
895
- }
983
+ });
896
984
  return results;
897
985
  });
898
986
  }
899
987
  /**
900
- * Accept a batch inside the caller's own open transaction. This path must not take the
901
- * agent's persistence lock: the caller holds the database writer for as long as its
902
- * transaction stays open, while a running turn takes the lock first and the database second,
903
- * so waiting for the lock here would close a cycle nothing could break. The outer transaction
904
- * supplies the atomicity the lock otherwise guarantees, queue keys are claimed with
905
- * absent-only writes so a racing independent enqueue cannot be overwritten, and no heap
906
- * state changes until the commit publishes the batch.
988
+ * Accept a batch inside the caller's own open transaction. The outer transaction supplies
989
+ * atomicity, queue keys are claimed with absent-only writes so a racing independent enqueue
990
+ * cannot be overwritten, and no heap state changes until the commit publishes the batch.
907
991
  */
908
992
  async #enqueueInTransaction(ctx, batch) {
909
993
  const results = [];
@@ -934,43 +1018,58 @@ export class AgentBase {
934
1018
  // Accepting a message is what makes the work owed, so the same transaction that admits
935
1019
  // it records that the agent owes an answer; here that record is staged durably and made
936
1020
  // live only once the outermost commit publishes the whole batch.
937
- const stagedPending = acceptedAny
938
- ? await this.#stagePendingMessageWork(ctx)
939
- : undefined;
1021
+ const staged = acceptedAny ? await this.#stagePendingMessageWork(ctx) : undefined;
1022
+ // Scheduling onto a settled agent is what makes it active; the announcement writes into
1023
+ // the caller's own transaction and is published or rolled back with the batch itself.
1024
+ if (staged?.activated === true)
1025
+ await this.#announceActivation(ctx, false);
940
1026
  const offeredIds = batch.map(({ id }) => id);
941
1027
  afterCommit(ctx, async () => {
942
- await this.#activateCommittedMessages(offeredIds, stagedPending);
1028
+ await this.#activateCommittedMessages(offeredIds, staged);
943
1029
  });
944
1030
  return results;
945
1031
  }
946
1032
  /**
947
1033
  * Persist the pending inference an outer transaction will make live after commit, without
948
- * changing any heap state that would survive a rollback. Reading the transaction's current
949
- * value also makes multiple sends in one outer transaction reuse the same lifecycle IDs.
1034
+ * changing any heap state that would survive a rollback. Whether the agent was settled is
1035
+ * decided by the store's own absent-only insert of the pending record, so concurrent
1036
+ * transactions waking the same agent are arbitrated by the database itself: exactly one
1037
+ * creates the record and activates. Reading the transaction's current value on the taken
1038
+ * path also makes multiple sends in one outer transaction reuse the same lifecycle IDs.
950
1039
  */
951
1040
  async #stagePendingMessageWork(ctx) {
1041
+ const fresh = { stage: "inference", loopId: createId() };
1042
+ if (await this.#persistence.writeValueIfAbsent(ctx, AGENT_BASE_PENDING_KEY, fresh)) {
1043
+ return { pending: fresh, activated: true };
1044
+ }
952
1045
  const stored = await agentBasePendingStateOf(ctx, this.#persistence);
953
- const pending = stored === undefined
954
- ? { stage: "inference", loopId: createId() }
955
- : { ...stored, stage: "inference" };
1046
+ // A record that is present but unreadable counts as no pending state, as everywhere
1047
+ // else, so this transaction still claims the activation when it replaces one.
1048
+ const pending = stored === undefined ? fresh : { ...stored, stage: "inference" };
956
1049
  await this.#persistence.writeValue(ctx, AGENT_BASE_PENDING_KEY, pending);
957
- return pending;
1050
+ return { pending, activated: stored === undefined };
958
1051
  }
959
1052
  /**
960
1053
  * Publish a transactionally accepted message only after the outermost commit. The next turn
961
1054
  * reloads the durable queue instead of copying staged entries into memory; the dirty marker
962
1055
  * prevents a turn already in flight from clearing the request before that reload happens.
963
1056
  */
964
- async #activateCommittedMessages(offeredIds, stagedPending) {
1057
+ async #activateCommittedMessages(offeredIds, staged) {
965
1058
  for (const id of offeredIds)
966
1059
  this.#offeredMessageIds.add(id);
967
- if (stagedPending === undefined)
1060
+ if (staged === undefined)
968
1061
  return;
969
- await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
970
- let pending = stagedPending;
1062
+ // The commit published the staged activation announcement along with the batch, so the
1063
+ // current active period is now an announced one.
1064
+ if (staged.activated) {
1065
+ this.#activationAnnounced = true;
1066
+ this.#restoreActivationOwed = false;
1067
+ }
1068
+ await this.#runPersistenceStep(this.#ctx, async (lockCtx) => {
1069
+ let pending = staged.pending;
971
1070
  try {
972
1071
  pending =
973
- (await agentBasePendingStateOf(lockCtx, this.#persistence)) ?? stagedPending;
1072
+ (await agentBasePendingStateOf(lockCtx, this.#persistence)) ?? staged.pending;
974
1073
  }
975
1074
  catch {
976
1075
  // The committed staged value is enough to start; a turn reload retries storage.
@@ -1002,22 +1101,31 @@ export class AgentBase {
1002
1101
  return;
1003
1102
  if (this.#closed)
1004
1103
  throw new Error("The agent has been closed.");
1005
- await this.#runInPersistenceLock(ctx, async (lockCtx) => {
1104
+ await this.#runPersistenceStep(ctx, async (lockCtx) => {
1006
1105
  const accepted = [];
1007
1106
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1107
+ let activated = false;
1008
1108
  for (const message of batch) {
1009
1109
  const key = await this.#queueKey(txCtx, "inject.");
1010
1110
  await this.#persistence.writeValue(txCtx, key, message);
1011
1111
  accepted.push({ key, message });
1012
1112
  }
1013
- if (accepted.length > 0)
1014
- await this.#recordPending(txCtx, "inference");
1113
+ if (accepted.length === 0)
1114
+ return;
1115
+ if (await this.#claimPendingWork(txCtx)) {
1116
+ await this.#announceActivation(txCtx, false);
1117
+ activated = true;
1118
+ }
1119
+ afterCommit(txCtx, () => {
1120
+ if (activated) {
1121
+ this.#activationAnnounced = true;
1122
+ this.#restoreActivationOwed = false;
1123
+ }
1124
+ this.#injections.push(...accepted);
1125
+ this.#turnRequested = true;
1126
+ this.#startRun();
1127
+ });
1015
1128
  });
1016
- this.#injections.push(...accepted);
1017
- if (accepted.length > 0) {
1018
- this.#turnRequested = true;
1019
- this.#startRun();
1020
- }
1021
1129
  });
1022
1130
  }
1023
1131
  /**
@@ -1303,7 +1411,7 @@ export class AgentBase {
1303
1411
  return;
1304
1412
  // The loop is a lifetime of its own, so it marks itself rather than inheriting whatever
1305
1413
  // happened to start it — a tool of another agent, most often, which will be long gone.
1306
- this.#runPromise = insideLoops
1414
+ this.#runPromise = outsideAgentDatabaseOperation(() => insideLoops
1307
1415
  .run([this.id], () => this.#runLoop())
1308
1416
  .finally(() => {
1309
1417
  this.#runPromise = undefined;
@@ -1315,7 +1423,7 @@ export class AgentBase {
1315
1423
  return;
1316
1424
  }
1317
1425
  this.#announceSettled();
1318
- });
1426
+ }));
1319
1427
  }
1320
1428
  /**
1321
1429
  * Answer turns until nothing is asked for any more. The inner loop is one turn each: reload
@@ -1328,13 +1436,14 @@ export class AgentBase {
1328
1436
  // whole of it runs on. Everything below is handed that context rather than reading a
1329
1437
  // scope back off the agent, so each turn, inference, tool call, and hook is placed in
1330
1438
  // the run it actually belongs to even while another agent is running alongside it.
1331
- await this.#ctx.span("agent.run", (ctx) => this.#runTurns(ctx));
1439
+ await this.#span(this.#ctx, "agent.run", {}, (ctx) => this.#runTurns(ctx));
1332
1440
  }
1333
1441
  /** The run itself, on the context of the span the whole of it belongs to. */
1334
1442
  async #runTurns(ctx) {
1335
1443
  if (this.#pending?.stage === "settlement") {
1336
1444
  this.#loopId ??= createId();
1337
1445
  this.#settlementId ??= createId();
1446
+ setAgentSpanAttributes(ctx, { "agent.loop.id": this.#loopId });
1338
1447
  await this.#settleDurably(ctx, {
1339
1448
  loopId: this.#loopId,
1340
1449
  settlementId: this.#settlementId,
@@ -1346,6 +1455,7 @@ export class AgentBase {
1346
1455
  do {
1347
1456
  this.#loopId ??= createId();
1348
1457
  const loop = { loopId: this.#loopId };
1458
+ setAgentSpanAttributes(ctx, { "agent.loop.id": loop.loopId });
1349
1459
  // The abort scope opens before the loop hook, not just before the turn. An abort
1350
1460
  // owns everything the run does — its opening hook as much as its inference — so a
1351
1461
  // run cancelled while it is still starting up never reaches the model at all.
@@ -1359,7 +1469,7 @@ export class AgentBase {
1359
1469
  : (hookCtx) => this.#hooks.beforeAgentLoopTransact?.(hookCtx, loop));
1360
1470
  await this.#invokeHook(ctx, this.#hooks.beforeAgentLoop, loop);
1361
1471
  do {
1362
- const outcome = await ctx.span("agent.turn", (turnCtx) => this.#runTurn(turnCtx, loop, abort));
1472
+ const outcome = await this.#span(ctx, "agent.turn", { "agent.loop.id": loop.loopId }, (turnCtx) => this.#runTurn(turnCtx, loop, abort));
1363
1473
  if (outcome === "blocked")
1364
1474
  return;
1365
1475
  if (outcome === "stop")
@@ -1394,6 +1504,7 @@ export class AgentBase {
1394
1504
  this.#turnAborted = false;
1395
1505
  this.#durableWorkBlocked = false;
1396
1506
  this.#turnId ??= createId();
1507
+ setAgentSpanAttributes(ctx, { "agent.turn.id": this.#turnId });
1397
1508
  // Claimed before any awaiting, so a request raised while the turn is still starting up
1398
1509
  // survives into another turn instead of being cleared by it. The redundant turn this can
1399
1510
  // cost is cheap: an empty queue drains without any inference.
@@ -1459,12 +1570,15 @@ export class AgentBase {
1459
1570
  * resumed at all.
1460
1571
  */
1461
1572
  async #settleDurably(ctx, settlement) {
1462
- await ctx.span("agent.settle", (settleCtx) => this.#settleRecord(settleCtx, settlement));
1573
+ await this.#span(ctx, "agent.settle", {
1574
+ "agent.loop.id": settlement.loopId,
1575
+ "agent.settlement.id": settlement.settlementId,
1576
+ }, (settleCtx) => this.#settleRecord(settleCtx, settlement));
1463
1577
  }
1464
1578
  /** The settlement itself, on the context of the span it belongs to. */
1465
1579
  async #settleRecord(ctx, settlement) {
1466
1580
  try {
1467
- await this.#runInPersistenceLock(this.#workContext(ctx), (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
1581
+ await this.#runPersistenceStep(this.#workContext(ctx), (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
1468
1582
  await this.#clearPending(txCtx);
1469
1583
  await this.#invokeTransactionalSettle(txCtx, settlement);
1470
1584
  // The run store is erased last, so a settling hook can still read what the
@@ -1478,6 +1592,10 @@ export class AgentBase {
1478
1592
  this.#inferenceId = undefined;
1479
1593
  this.#settlementId = undefined;
1480
1594
  this.#settlement = settlement;
1595
+ // Settled means nothing is owed: the next scheduled message activates the agent
1596
+ // afresh, and nothing inherited remains to announce.
1597
+ this.#activationAnnounced = false;
1598
+ this.#restoreActivationOwed = false;
1481
1599
  }
1482
1600
  catch {
1483
1601
  // The run itself is over and succeeded; only the record of its ending failed.
@@ -1721,6 +1839,7 @@ export class AgentBase {
1721
1839
  await this.#emit(ctx, { type: "done", state: "cancelled" });
1722
1840
  break;
1723
1841
  }
1842
+ await this.#finishAdmittedQueueWrites();
1724
1843
  let injected = await this.#consumeQueue(ctx, this.#steering, this.#steeringMode, "steering");
1725
1844
  if (!injected && !needsInference) {
1726
1845
  injected = await this.#consumeQueue(ctx, this.#sends, this.#sendMode, "send");
@@ -1731,7 +1850,7 @@ export class AgentBase {
1731
1850
  // Nothing to answer — a start() on an idle history, or the queues ran dry.
1732
1851
  if (!this.#noticeAwaitingResponse && !injected && !needsInference)
1733
1852
  break;
1734
- const response = await ctx.span("agent.inference", (inferenceCtx) => this.#requestInference(inferenceCtx, abortPromise));
1853
+ const response = await this.#span(ctx, "agent.inference", {}, (inferenceCtx) => this.#requestInference(inferenceCtx, abortPromise));
1735
1854
  // A cancellation arrived before the request was made, so the turn cycles rather
1736
1855
  // than talking to a session it may no longer own.
1737
1856
  if (response === undefined)
@@ -1793,6 +1912,16 @@ export class AgentBase {
1793
1912
  }
1794
1913
  this.#turnAborted = abort.signal.aborted;
1795
1914
  }
1915
+ /**
1916
+ * Let queue writes already admitted by a re-entrant hook publish before deciding the queues
1917
+ * are empty. Their database transactions own ordering; this waits for those concrete writes
1918
+ * without holding another agent or database lock.
1919
+ */
1920
+ async #finishAdmittedQueueWrites() {
1921
+ while (this.#messageAdmissions.size > 0) {
1922
+ await Promise.allSettled(this.#messageAdmissions);
1923
+ }
1924
+ }
1796
1925
  /**
1797
1926
  * Make one request of the provider and take its answer: the instructions and tools the turn
1798
1927
  * is running with, the session they belong to, the inference brackets around the request, and
@@ -1817,6 +1946,11 @@ export class AgentBase {
1817
1946
  };
1818
1947
  this.#loopId = inferenceStart.loopId;
1819
1948
  this.#turnId = inferenceStart.turnId;
1949
+ setAgentSpanAttributes(ctx, {
1950
+ "agent.loop.id": inferenceStart.loopId,
1951
+ "agent.turn.id": inferenceStart.turnId,
1952
+ "agent.inference.id": inferenceStart.inferenceId,
1953
+ });
1820
1954
  await this.#enterStage(ctx, "inference", this.#hooks.beforeInferenceTransact === undefined
1821
1955
  ? undefined
1822
1956
  : (hookCtx) => this.#hooks.beforeInferenceTransact?.(hookCtx, inferenceStart));
@@ -1908,7 +2042,7 @@ export class AgentBase {
1908
2042
  const previousTokens = this.#contextTokens;
1909
2043
  this.#contextTokens = tokens;
1910
2044
  try {
1911
- await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
2045
+ await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
1912
2046
  const write = (writeCtx) => tokens === undefined
1913
2047
  ? this.#persistence.deleteValue(writeCtx, "context")
1914
2048
  : this.#persistence.writeValue(writeCtx, "context", { tokens });
@@ -1942,7 +2076,7 @@ export class AgentBase {
1942
2076
  async #runCompaction(ctx, signal) {
1943
2077
  if (this.#compaction === undefined)
1944
2078
  return;
1945
- await ctx.span("agent.compaction", (compactionCtx) => this.#compactHistory(compactionCtx, signal));
2079
+ await this.#span(ctx, "agent.compaction", {}, (compactionCtx) => this.#compactHistory(compactionCtx, signal));
1946
2080
  }
1947
2081
  /** The compaction itself, on the context of the span it belongs to. */
1948
2082
  async #compactHistory(ctx, signal) {
@@ -1963,6 +2097,11 @@ export class AgentBase {
1963
2097
  };
1964
2098
  this.#loopId = compactionStart.loopId;
1965
2099
  this.#turnId = compactionStart.turnId;
2100
+ setAgentSpanAttributes(ctx, {
2101
+ "agent.loop.id": compactionStart.loopId,
2102
+ "agent.turn.id": compactionStart.turnId,
2103
+ "agent.compaction.id": compactionStart.compactionId,
2104
+ });
1966
2105
  await this.#invokeHook(ctx, this.#hooks.beforeCompaction, compactionStart);
1967
2106
  // Provider compaction is this turn's work, so it runs on this turn's lifetime: an
1968
2107
  // abort reaches the provider operation itself rather than waiting for it to finish
@@ -1980,7 +2119,7 @@ export class AgentBase {
1980
2119
  }
1981
2120
  if (result.status === "completed") {
1982
2121
  const completed = { ...compactionStart, result };
1983
- await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
2122
+ await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
1984
2123
  // Physically delete the superseded records and write the replacement —
1985
2124
  // which keeps the messages that stay — in one atomic step.
1986
2125
  await this.#recordTransaction(lockCtx, async (txCtx) => {
@@ -2034,7 +2173,7 @@ export class AgentBase {
2034
2173
  let settled = false;
2035
2174
  let staged = false;
2036
2175
  try {
2037
- await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
2176
+ await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
2038
2177
  // A call the durable batch still holds belongs to the resume, which answers it
2039
2178
  // properly — and re-executes it when the tool is durable. Settling it here as
2040
2179
  // well would give the conversation two results for one call.
@@ -2142,7 +2281,7 @@ export class AgentBase {
2142
2281
  content: [{ type: "text", text: `The last turn failed: ${message}` }],
2143
2282
  };
2144
2283
  try {
2145
- await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
2284
+ await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
2146
2285
  await this.#appendRecord(lockCtx, { type: "system", message: failure });
2147
2286
  this.#messages.push(failure);
2148
2287
  });
@@ -2153,7 +2292,7 @@ export class AgentBase {
2153
2292
  }
2154
2293
  /** Move every pending hook notice into history as one atomic, ordered append batch. */
2155
2294
  async #consumeInjections(ctx) {
2156
- return await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
2295
+ return await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
2157
2296
  if (this.#injections.length === 0)
2158
2297
  return false;
2159
2298
  const durable = new Set((await this.#persistence.readValues(lockCtx, "inject.")).map(({ key }) => key));
@@ -2202,16 +2341,16 @@ export class AgentBase {
2202
2341
  * context store and the in-memory history. The moves run in one transaction, so a message
2203
2342
  * is never durable in both stores or neither, and memory changes only after the commit.
2204
2343
  *
2205
- * What the consumption has to announce is announced once the lock has been released. A hook
2206
- * told a message has landed may perfectly well answer by sending another one, and doing that
2207
- * while this still held the store lock would be the hook waiting for its own caller.
2344
+ * What the consumption has to announce is announced once the transaction has committed. A
2345
+ * hook told a message has landed may perfectly well answer by sending another one without
2346
+ * re-entering the transaction that delivered the first message.
2208
2347
  */
2209
2348
  async #consumeQueue(ctx, queue, mode, kind) {
2210
2349
  const prefix = `${kind}.`;
2211
- /** Filled in once the consumption has committed, and reported after the lock is released. */
2350
+ /** Filled in once the consumption has committed, then reported to observers. */
2212
2351
  const accepted = [];
2213
2352
  let permissionChange;
2214
- const consumed = await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
2353
+ const consumed = await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
2215
2354
  if (queue.length === 0)
2216
2355
  return false;
2217
2356
  // The durable queue, not memory, decides what is left to consume after a restart.
@@ -2294,8 +2433,7 @@ export class AgentBase {
2294
2433
  await this.#recordTransaction(lockCtx, async (txCtx) => {
2295
2434
  if (selectionChanged) {
2296
2435
  if (this.#hooks.modelChanged !== undefined && model !== undefined) {
2297
- // The hook runs while the persistence lock is held and inside the
2298
- // transaction that commits the switch, so its store executes directly on
2436
+ // The hook runs inside the transaction that commits the switch, so its store executes directly on
2299
2437
  // that transaction: what it writes lands and rolls back with the change
2300
2438
  // it was told about, never on its own. The context it is given ends with
2301
2439
  // the transaction, so a store it keeps cannot outlive the switch.
@@ -2393,16 +2531,14 @@ export class AgentBase {
2393
2531
  id: entry.id,
2394
2532
  kind,
2395
2533
  message: entry.message,
2396
- ...(entry.metadata === undefined
2397
- ? {}
2398
- : { metadata: entry.metadata }),
2534
+ ...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
2399
2535
  });
2400
2536
  }
2401
2537
  });
2402
2538
  if (reset)
2403
2539
  this.#rotateHistoryKV();
2404
2540
  // Committed: from here the messages are part of the conversation, so what has to be
2405
- // announced about them is decided now and reported once the lock is released.
2541
+ // announced about them is decided now and reported once the transaction commits.
2406
2542
  permissionChange = modeChange;
2407
2543
  accepted.push(...batch.map((entry) => ({
2408
2544
  id: entry.id,
@@ -2452,13 +2588,13 @@ export class AgentBase {
2452
2588
  await this.#withTransactionalContext(hookCtx, (liveCtx) => hook(liveCtx, argument));
2453
2589
  }
2454
2590
  /**
2455
- * Replace the in-memory state with the durable one. The persistence lock guarantees every
2591
+ * Replace the in-memory state with the durable one. Durable queue acceptance guarantees every
2456
2592
  * message already in memory reached storage first, so the load result supersedes memory
2457
2593
  * entirely: the main store rebuilds the context, and the sorted queue keys rebuild the
2458
2594
  * not-yet-consumed queues. Consecutive block records reassemble into one assistant message.
2459
2595
  */
2460
2596
  async #loadHistory(ctx) {
2461
- await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
2597
+ await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
2462
2598
  const records = await this.#persistence.load(lockCtx);
2463
2599
  const last = records[records.length - 1];
2464
2600
  this.#lastRecordType = last?.type;
@@ -2540,12 +2676,12 @@ export class AgentBase {
2540
2676
  * complete context behind.
2541
2677
  */
2542
2678
  async #runToolBatch(ctx, entries, resume, signal, abortPromise) {
2543
- return await ctx.span("agent.tools", (batchCtx) => this.#dispatchToolBatch(batchCtx, entries, resume, signal, abortPromise));
2679
+ return await this.#span(ctx, "agent.tools", { "agent.tool.count": entries.length, "agent.tool.resume": resume }, (batchCtx) => this.#dispatchToolBatch(batchCtx, entries, resume, signal, abortPromise));
2544
2680
  }
2545
2681
  /** The batch itself, on the context of the span it belongs to. */
2546
2682
  async #dispatchToolBatch(ctx, entries, resume, signal, abortPromise) {
2547
2683
  if (!resume) {
2548
- await this.#runInPersistenceLock(this.#workContext(ctx), (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
2684
+ await this.#runPersistenceStep(this.#workContext(ctx), (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
2549
2685
  for (const entry of entries) {
2550
2686
  await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry(entry));
2551
2687
  }
@@ -2577,7 +2713,7 @@ export class AgentBase {
2577
2713
  if (commitFailed)
2578
2714
  return;
2579
2715
  try {
2580
- await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
2716
+ await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
2581
2717
  while (committed < entries.length) {
2582
2718
  const entry = entries[committed];
2583
2719
  const proposed = results[committed];
@@ -2639,7 +2775,13 @@ export class AgentBase {
2639
2775
  // The call's own span hangs off the batch's. Every call in the batch runs at
2640
2776
  // the same time, so each opens its span from the batch's context and carries
2641
2777
  // its own from there.
2642
- const execution = ctx.span("agent.tool", (toolCtx) => this.#executeToolCall(withLifetime(this.#workContext(toolCtx), toolLifetime), entry));
2778
+ const execution = this.#span(ctx, "agent.tool", {
2779
+ "agent.tool.id": entry.id,
2780
+ "agent.tool.name": entry.call.name,
2781
+ ...(entry.call.namespace === undefined
2782
+ ? {}
2783
+ : { "agent.tool.namespace": entry.call.namespace }),
2784
+ }, (toolCtx) => this.#executeToolCall(withLifetime(this.#workContext(toolCtx), toolLifetime), entry));
2643
2785
  running.push(execution);
2644
2786
  outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
2645
2787
  }
@@ -3026,7 +3168,7 @@ export class AgentBase {
3026
3168
  }
3027
3169
  /**
3028
3170
  * Write a queue entry under a key nothing else holds. An enqueue running outside the
3029
- * persistence lock can race an independent one for the tail position, so the key is claimed
3171
+ * database transaction can race an independent one for the tail position, so the key is claimed
3030
3172
  * with an absent-only write and a taken key moves one sequence further rather than
3031
3173
  * overwriting whatever claimed it first.
3032
3174
  */
@@ -3055,7 +3197,7 @@ export class AgentBase {
3055
3197
  const persist = async (event) => {
3056
3198
  if (event === undefined)
3057
3199
  return;
3058
- await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
3200
+ await this.#runPersistenceStep(this.#workContext(ctx), async (lockCtx) => {
3059
3201
  if (this.#hooks.onEventTransact === undefined) {
3060
3202
  await this.#appendRecord(lockCtx, { type: "block", block: event.block });
3061
3203
  }