@slopus/happy-agent-base 0.0.4 → 0.0.6

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 (52) hide show
  1. package/README.md +35 -1
  2. package/dist/Agent.d.ts +3 -0
  3. package/dist/Agent.d.ts.map +1 -1
  4. package/dist/Agent.js +56 -20
  5. package/dist/Agent.js.map +1 -1
  6. package/dist/AgentBase.d.ts +36 -0
  7. package/dist/AgentBase.d.ts.map +1 -1
  8. package/dist/AgentBase.js +376 -58
  9. package/dist/AgentBase.js.map +1 -1
  10. package/dist/AgentBaseHooks.d.ts +166 -20
  11. package/dist/AgentBaseHooks.d.ts.map +1 -1
  12. package/dist/AgentConfig.d.ts +12 -5
  13. package/dist/AgentConfig.d.ts.map +1 -1
  14. package/dist/AgentConfig.js +25 -5
  15. package/dist/AgentConfig.js.map +1 -1
  16. package/dist/AgentContexts.d.ts +19 -5
  17. package/dist/AgentContexts.d.ts.map +1 -1
  18. package/dist/AgentContexts.js +26 -6
  19. package/dist/AgentContexts.js.map +1 -1
  20. package/dist/AgentFeature.d.ts +48 -10
  21. package/dist/AgentFeature.d.ts.map +1 -1
  22. package/dist/AgentFeatureAction.d.ts +5 -0
  23. package/dist/AgentFeatureAction.d.ts.map +1 -1
  24. package/dist/AgentMetadata.d.ts +37 -0
  25. package/dist/AgentMetadata.d.ts.map +1 -0
  26. package/dist/AgentMetadata.js +74 -0
  27. package/dist/AgentMetadata.js.map +1 -0
  28. package/dist/AgentPermissionMode.d.ts +27 -0
  29. package/dist/AgentPermissionMode.d.ts.map +1 -0
  30. package/dist/AgentPermissionMode.js +48 -0
  31. package/dist/AgentPermissionMode.js.map +1 -0
  32. package/dist/AgentPersistence.d.ts +14 -4
  33. package/dist/AgentPersistence.d.ts.map +1 -1
  34. package/dist/AgentRef.d.ts +6 -1
  35. package/dist/AgentRef.d.ts.map +1 -1
  36. package/dist/AgentRef.js +8 -1
  37. package/dist/AgentRef.js.map +1 -1
  38. package/dist/AgentSystem.d.ts +26 -7
  39. package/dist/AgentSystem.d.ts.map +1 -1
  40. package/dist/AgentSystemLocal.d.ts +14 -8
  41. package/dist/AgentSystemLocal.d.ts.map +1 -1
  42. package/dist/AgentSystemLocal.js +108 -32
  43. package/dist/AgentSystemLocal.js.map +1 -1
  44. package/dist/AgentSystemRef.d.ts +14 -5
  45. package/dist/AgentSystemRef.d.ts.map +1 -1
  46. package/dist/AgentSystemRef.js +25 -6
  47. package/dist/AgentSystemRef.js.map +1 -1
  48. package/dist/index.d.ts +6 -4
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +5 -3
  51. package/dist/index.js.map +1 -1
  52. package/package.json +1 -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";
3
4
  import { Value } from "@sinclair/typebox/value";
4
5
  import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
5
- import { withAgentContext, withAgentKV, withAgentRunKV } from "./AgentContexts.js";
6
+ import { withAgentContext, withAgentKV, withAgentPermissionMode, withAgentRunKV, } from "./AgentContexts.js";
7
+ import { agentConfig, ownAgentConfig, withAgentConfig } from "./AgentConfig.js";
6
8
  import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
7
9
  import { AgentKV } from "./AgentKV.js";
8
10
  import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
11
+ import { cuid2Schema, ownAgentMessageMetadata, ownAgentMetadata, } from "./AgentMetadata.js";
12
+ import { DEFAULT_AGENT_PERMISSION_MODE, isAgentPermissionMode, } from "./AgentPermissionMode.js";
9
13
  import { AgentProviders } from "./AgentProviders.js";
10
14
  /** Race winner when an abort interrupts a wait on the stream or a running tool. */
11
15
  const ABORTED = Symbol("aborted");
@@ -24,6 +28,8 @@ const insideTurn = createContextNamespace("agentInsideTurn", []);
24
28
  * its own and outlives whatever happened to start it.
25
29
  */
26
30
  const insideLoops = new AsyncLocalStorage();
31
+ /** Persistence locks held by the current asynchronous call chain, independent of Context. */
32
+ const insidePersistenceLocks = new AsyncLocalStorage();
27
33
  /**
28
34
  * How long a close asked for from inside the agent's own run loop waits for the shutdown before
29
35
  * telling its caller it cannot be waited for. Long enough that a caller which has already let go
@@ -103,6 +109,20 @@ const INSIDE_CLOSE_REPORT_MS = 15;
103
109
  * capability released when the hook returns, so it cannot be retained to bypass the lock later.
104
110
  * A failing handoff rejects an incompatible switch outright rather than costing the history.
105
111
  *
112
+ * ## Permission modes
113
+ *
114
+ * How much of the machine the agent may touch travels with its messages, exactly like its model,
115
+ * and takes effect when the message is consumed rather than when it is queued: a response and the
116
+ * tools it dispatched are already running under the mode they were started with, and are left to
117
+ * finish under it. The mode is durable, so a restart resumes in the mode the conversation reached,
118
+ * and it is carried on every context the agent derives, so a hook or a tool reads what it is
119
+ * running under rather than being told.
120
+ *
121
+ * The loop enforces nothing. It has no idea what any particular tool touches, and a runtime that
122
+ * guessed would be wrong about tools it has never seen. Enforcement belongs to the features and
123
+ * tools that do know; the loop's whole part is to carry the mode, make its changes durable, and
124
+ * report them.
125
+ *
106
126
  * ## Recovery
107
127
  *
108
128
  * Whether a restart owes a response is decided by the last durable record: a consumed message, a
@@ -188,6 +208,8 @@ export class AgentBase {
188
208
  #baseCtx;
189
209
  /** The base context extended with the effective selection and the agent's key-value store. */
190
210
  #ctx;
211
+ /** The immutable configuration snapshot carried by every hook context. */
212
+ #config;
191
213
  /** The registry the provider ID is resolved through, each time a session is created. */
192
214
  #providers;
193
215
  /** The registry ID of the provider in force; durable, so a restart resumes on the same one. */
@@ -200,6 +222,11 @@ export class AgentBase {
200
222
  #effort;
201
223
  /** The service tier in force. */
202
224
  #serviceTier;
225
+ /**
226
+ * How much of the machine the agent may touch. Durable, so a restart resumes in the mode the
227
+ * conversation reached, and carried on every context the agent derives.
228
+ */
229
+ #permissionMode;
203
230
  /** The single set of hooks the run is observed by and its configuration extended from. */
204
231
  #hooks;
205
232
  /**
@@ -368,7 +395,8 @@ export class AgentBase {
368
395
  * by, so reading it here leaves the later stage writes nothing to learn from the store.
369
396
  */
370
397
  async #loadPendingState() {
371
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
398
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
399
+ await this.#loadConfig(lockCtx);
372
400
  const stored = await agentBasePendingStateOf(lockCtx, this.#persistence);
373
401
  this.#inherited = stored;
374
402
  this.#inheritedRead = true;
@@ -385,10 +413,11 @@ export class AgentBase {
385
413
  */
386
414
  constructor(ctx, options) {
387
415
  this.id = options.id;
416
+ this.#config = ownAgentConfig(agentConfig(ctx) ?? {});
388
417
  // An agent is its own lifetime. Whatever call happened to construct it — a tool of
389
418
  // another agent, most often — is not a loop this one runs inside, so an inherited
390
419
  // marker is dropped rather than carried into work that outlives that call.
391
- this.#baseCtx = insideTurn.set(ctx, [options.id]);
420
+ this.#baseCtx = withAgentConfig(insideTurn.set(ctx, [options.id]), this.#config);
392
421
  this.#providers = options.providers;
393
422
  this.#providerId = options.provider;
394
423
  this.#persistence = options.persistence;
@@ -401,6 +430,7 @@ export class AgentBase {
401
430
  this.#model = options.model;
402
431
  this.#effort = options.effort;
403
432
  this.#serviceTier = options.serviceTier;
433
+ this.#permissionMode = options.permissionMode ?? DEFAULT_AGENT_PERMISSION_MODE;
404
434
  this.#kv = new AgentKV(this.#persistence, `kv.${options.id}.`);
405
435
  this.#runKV = this.#kv.scoped("run");
406
436
  // Everything the agent does — hooks and tool executions included — runs on a context
@@ -415,14 +445,34 @@ export class AgentBase {
415
445
  * the selection changes.
416
446
  */
417
447
  #deriveCtx() {
418
- const ctx = withAgentContext(this.#baseCtx, {
448
+ return this.#hookContext(this.#baseCtx);
449
+ }
450
+ /** Add this agent's selection and stores to a caller context without losing its transaction. */
451
+ #hookContext(ctx) {
452
+ const selected = withAgentContext(ctx, this.#selection());
453
+ return withAgentRunKV(withAgentKV(selected, this.#kv), this.#runKV);
454
+ }
455
+ /** Load a directly owned configuration written by `updateMetadata`, when one exists. */
456
+ async #loadConfig(ctx) {
457
+ const stored = await this.#persistence.readValues(ctx, "agentConfig");
458
+ const exact = stored.find(({ key }) => key === "agentConfig")?.value;
459
+ if (exact === undefined)
460
+ return;
461
+ const config = ownAgentConfig(exact);
462
+ this.#config = config;
463
+ this.#baseCtx = withAgentConfig(this.#baseCtx, config);
464
+ this.#ctx = this.#deriveCtx();
465
+ }
466
+ /** Everything about what the agent is currently running on, as one value to carry. */
467
+ #selection() {
468
+ return {
419
469
  id: this.id,
420
470
  provider: this.#providerId,
421
471
  model: this.#model,
422
472
  effort: this.#effort,
423
473
  serviceTier: this.#serviceTier,
424
- });
425
- return withAgentRunKV(withAgentKV(ctx, this.#kv), this.#runKV);
474
+ permissionMode: this.#permissionMode,
475
+ };
426
476
  }
427
477
  /**
428
478
  * Whether the agent has anything left to do. This is the only thing about an agent's state
@@ -463,7 +513,7 @@ export class AgentBase {
463
513
  return undefined;
464
514
  }
465
515
  try {
466
- return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
516
+ return await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
467
517
  if (!this.#inheritedRead) {
468
518
  this.#inheritedRead = true;
469
519
  this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
@@ -519,19 +569,72 @@ export class AgentBase {
519
569
  async send(ctx, message, options) {
520
570
  await this.#offer(ctx, "send", message, options);
521
571
  }
572
+ /**
573
+ * Shallow-merge fields into this agent's immutable metadata. The complete AgentConfig and
574
+ * transactional hook writes commit together; observing hooks run only after that commit.
575
+ */
576
+ async updateMetadata(ctx, update) {
577
+ const ownedUpdate = ownAgentMetadata(update);
578
+ if (ownedUpdate === undefined)
579
+ throw new Error("The agent metadata is not valid.");
580
+ if (this.#closed)
581
+ throw new Error("The agent has been closed.");
582
+ if (insideTurn.get(ctx).includes(this.id) ||
583
+ this.#insideOwnLoop() ||
584
+ this.#insideOwnPersistenceLock()) {
585
+ throw new Error("Updating metadata from inside this agent's current operation would wait for " +
586
+ "that same operation to finish. Update it after the hook or tool returns.");
587
+ }
588
+ let change;
589
+ let next;
590
+ await this.#runInPersistenceLock(ctx, async (lockCtx) => {
591
+ const previousMetadata = ownAgentMetadata(this.#config.metadata ?? {});
592
+ const metadata = ownAgentMetadata({ ...previousMetadata, ...ownedUpdate });
593
+ if (previousMetadata === undefined || metadata === undefined) {
594
+ throw new Error("The agent metadata is not valid.");
595
+ }
596
+ next = ownAgentConfig({ ...this.#config, metadata });
597
+ change = {
598
+ agentId: this.id,
599
+ previousMetadata,
600
+ update: ownedUpdate,
601
+ metadata,
602
+ };
603
+ await this.#persistence.transaction(lockCtx, async (txCtx) => {
604
+ await this.#persistence.writeValue(txCtx, "agentConfig", next);
605
+ await this.#withTransactionalContext(withAgentConfig(txCtx, next), async (hookCtx) => await this.#hooks.metadataChangedTransact?.(this.#hookContext(hookCtx), change));
606
+ });
607
+ this.#config = next;
608
+ this.#baseCtx = withAgentConfig(this.#baseCtx, next);
609
+ this.#ctx = this.#deriveCtx();
610
+ });
611
+ await this.#invokeHookOn(this.#hookContext(withAgentConfig(ctx, next)), this.#hooks.metadataChanged, change);
612
+ }
522
613
  /**
523
614
  * Hand one message to a durable queue. The acceptance runs whether or not the caller waits
524
615
  * for it — an unwaited failure is still a message that never entered the conversation, and
525
616
  * the agent's own close still drains it, so nothing is dropped by not looking.
526
617
  */
527
618
  async #offer(ctx, kind, message, options) {
528
- const { await: wait = false, ...settings } = options ?? {};
619
+ const { await: wait = false, id = createId(), metadata: suppliedMetadata, ...settings } = options ?? {};
620
+ if (!Value.Check(cuid2Schema, id)) {
621
+ throw new Error("The message ID must be a cuid2 identity.");
622
+ }
623
+ const metadata = ownAgentMessageMetadata(suppliedMetadata);
529
624
  // Refusing the flag rather than the operation: a closed agent and a re-entrant wait are
530
625
  // both caller mistakes, and both are reported before any work is started.
531
626
  this.#assertCanWait(ctx, wait, kind === "steering" ? "a steered message" : "a sent message");
532
627
  if (this.#closed)
533
628
  throw new Error("The agent has been closed.");
534
- const accepted = this.#enqueue(ctx, [{ kind, message, options: settings }]);
629
+ const accepted = this.#enqueue(ctx, [
630
+ {
631
+ kind,
632
+ id,
633
+ message: structuredClone(message),
634
+ ...(metadata === undefined ? {} : { metadata }),
635
+ options: settings,
636
+ },
637
+ ]);
535
638
  if (wait)
536
639
  return accepted;
537
640
  accepted.catch(() => undefined);
@@ -559,6 +662,19 @@ export class AgentBase {
559
662
  #insideOwnLoop() {
560
663
  return insideLoops.getStore()?.includes(this.id) === true;
561
664
  }
665
+ /** Whether this call chain already holds this agent's persistence lock. */
666
+ #insideOwnPersistenceLock() {
667
+ return insidePersistenceLocks.getStore()?.includes(this.id) === true;
668
+ }
669
+ /** Hold the persistence lock while marking it independently of the caller's Context. */
670
+ async #runInPersistenceLock(ctx, work) {
671
+ return await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
672
+ const held = insidePersistenceLocks.getStore() ?? [];
673
+ return await insidePersistenceLocks.run([...held, this.id], async () => {
674
+ return await work(lockCtx);
675
+ });
676
+ });
677
+ }
562
678
  /**
563
679
  * Accept a batch of messages as one durable step. Every message is written under the same
564
680
  * hold of the persistence lock and inside one transaction, so a caller arriving while a
@@ -572,17 +688,25 @@ export class AgentBase {
572
688
  throw new Error("The agent has been closed.");
573
689
  // Admitted: from here on the messages are the agent's responsibility, and a close that
574
690
  // begins now waits for them rather than resolving over the top of them.
575
- const admitted = this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
691
+ const admitted = this.#runInPersistenceLock(ctx, async (lockCtx) => {
576
692
  const accepted = [];
577
693
  await this.#persistence.transaction(lockCtx, async (txCtx) => {
578
694
  for (const request of batch) {
695
+ const identityKey = `message.${request.id}`;
696
+ if (!(await this.#persistence.writeValueIfAbsent(txCtx, identityKey, true))) {
697
+ continue;
698
+ }
579
699
  const key = await this.#queueKey(txCtx, `${request.kind}.`);
580
700
  await this.#persistence.writeValue(txCtx, key, {
701
+ id: request.id,
581
702
  message: request.message,
703
+ ...(request.metadata === undefined ? {} : { metadata: request.metadata }),
582
704
  options: request.options,
583
705
  });
584
706
  accepted.push({ key, request });
585
707
  }
708
+ if (accepted.length === 0)
709
+ return;
586
710
  // Accepting a message is what makes the work owed: the same transaction that
587
711
  // admits it records that the agent owes an answer, so a process that dies right
588
712
  // here is discovered still owing it rather than looking idle over a full queue.
@@ -593,8 +717,16 @@ export class AgentBase {
593
717
  // one replaces the queue arrays wholesale, and a reference taken before the wait
594
718
  // would push the message into an array nobody reads again.
595
719
  const queue = request.kind === "steering" ? this.#steering : this.#sends;
596
- queue.push({ key, message: request.message, options: request.options });
720
+ queue.push({
721
+ key,
722
+ id: request.id,
723
+ message: request.message,
724
+ ...(request.metadata === undefined ? {} : { metadata: request.metadata }),
725
+ options: request.options,
726
+ });
597
727
  }
728
+ if (accepted.length === 0)
729
+ return;
598
730
  this.#turnRequested = true;
599
731
  this.#startRun();
600
732
  });
@@ -936,7 +1068,7 @@ export class AgentBase {
936
1068
  // durable exactly as it was for the next attempt.
937
1069
  const loadFailure = await this.#ensureLoaded().then(() => undefined, (error) => error);
938
1070
  if (loadFailure !== undefined) {
939
- this.#emit({
1071
+ await this.#emit({
940
1072
  type: "done",
941
1073
  state: "error",
942
1074
  kind: "internal_error",
@@ -985,7 +1117,7 @@ export class AgentBase {
985
1117
  */
986
1118
  async #settleDurably() {
987
1119
  try {
988
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
1120
+ await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
989
1121
  await this.#clearPending(txCtx);
990
1122
  await this.#invokeTransactionalSettle(txCtx);
991
1123
  // The run store is erased last, so a settling hook can still read what the
@@ -1138,9 +1270,21 @@ export class AgentBase {
1138
1270
  this.#ensureCompaction().catch(() => undefined);
1139
1271
  continue;
1140
1272
  }
1273
+ const id = action.id ?? createId();
1274
+ if (!Value.Check(cuid2Schema, id))
1275
+ continue;
1276
+ let metadata;
1277
+ try {
1278
+ metadata = ownAgentMessageMetadata(action.metadata);
1279
+ }
1280
+ catch {
1281
+ continue;
1282
+ }
1141
1283
  batch.push({
1142
1284
  kind: action.type === "steer" ? "steering" : "send",
1143
- message: action.message,
1285
+ id,
1286
+ message: structuredClone(action.message),
1287
+ ...(metadata === undefined ? {} : { metadata }),
1144
1288
  options: {},
1145
1289
  });
1146
1290
  }
@@ -1187,7 +1331,8 @@ export class AgentBase {
1187
1331
  let needsInference = resumed.length > 0;
1188
1332
  if (!this.#recoveryChecked) {
1189
1333
  this.#recoveryChecked = true;
1190
- needsInference ||= this.#resumesInterruptedRun();
1334
+ if (await this.#resumesInterruptedRun())
1335
+ needsInference = true;
1191
1336
  }
1192
1337
  // Each cycle first drains the queues, then runs one inference. Steering injects at
1193
1338
  // every stop between responses and always outranks sends; sent messages inject
@@ -1206,12 +1351,12 @@ export class AgentBase {
1206
1351
  if (abort.signal.aborted) {
1207
1352
  const hasPendingWork = needsInference || this.#steering.length > 0 || this.#sends.length > 0;
1208
1353
  if (hasPendingWork)
1209
- this.#emit({ type: "done", state: "cancelled" });
1354
+ await this.#emit({ type: "done", state: "cancelled" });
1210
1355
  break;
1211
1356
  }
1212
- let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering.");
1357
+ let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering");
1213
1358
  if (!injected && !needsInference) {
1214
- injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send.");
1359
+ injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send");
1215
1360
  }
1216
1361
  // Nothing to answer — a start() on an idle history, or the queues ran dry.
1217
1362
  if (!injected && !needsInference)
@@ -1297,7 +1442,7 @@ export class AgentBase {
1297
1442
  }
1298
1443
  }
1299
1444
  catch (error) {
1300
- this.#emit({
1445
+ await this.#emit({
1301
1446
  type: "done",
1302
1447
  state: "error",
1303
1448
  kind: "internal_error",
@@ -1324,12 +1469,13 @@ export class AgentBase {
1324
1469
  * beginning of a block that will now never arrive is told to drop it. Only finished blocks
1325
1470
  * are persisted, so the conversation is intact and it is the view being corrected.
1326
1471
  */
1327
- #resumesInterruptedRun() {
1472
+ async #resumesInterruptedRun() {
1328
1473
  const owed = this.#lastRecordType === "user" ||
1329
1474
  this.#lastRecordType === "tool" ||
1330
1475
  this.#lastRecordType === "system";
1331
- if (owed && this.#inherited?.stage === "inference")
1332
- this.#emit({ type: "block_reset" });
1476
+ if (owed && this.#inherited?.stage === "inference") {
1477
+ await this.#emit({ type: "block_reset" });
1478
+ }
1333
1479
  return owed;
1334
1480
  }
1335
1481
  /** Load the durable state once. A failed load is not sticky: the next turn retries it. */
@@ -1349,7 +1495,7 @@ export class AgentBase {
1349
1495
  const previousTokens = this.#contextTokens;
1350
1496
  this.#contextTokens = tokens;
1351
1497
  try {
1352
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1498
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1353
1499
  const write = (writeCtx) => tokens === undefined
1354
1500
  ? this.#persistence.deleteValue(writeCtx, "context")
1355
1501
  : this.#persistence.writeValue(writeCtx, "context", { tokens });
@@ -1400,10 +1546,11 @@ export class AgentBase {
1400
1546
  throw new Error(result.message);
1401
1547
  }
1402
1548
  if (result.status === "completed") {
1403
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1549
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1404
1550
  // Physically delete the superseded records and write the replacement —
1405
1551
  // which keeps the messages that stay — in one atomic step.
1406
1552
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1553
+ await this.#deleteMessageIdentities(txCtx, await this.#persistence.load(txCtx));
1407
1554
  await this.#persistence.clearRecords(txCtx);
1408
1555
  await this.#persistence.append(txCtx, {
1409
1556
  type: "compaction",
@@ -1443,7 +1590,7 @@ export class AgentBase {
1443
1590
  return true;
1444
1591
  let settled = false;
1445
1592
  try {
1446
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1593
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1447
1594
  // A call the durable batch still holds belongs to the resume, which answers it
1448
1595
  // properly — and re-executes it when the tool is durable. Settling it here as
1449
1596
  // well would give the conversation two results for one call.
@@ -1457,6 +1604,10 @@ export class AgentBase {
1457
1604
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1458
1605
  for (const result of results) {
1459
1606
  await this.#appendRecord(txCtx, { type: "tool", message: result });
1607
+ // A result the conversation records is a result the hook sees, however
1608
+ // little of a run produced it. A hook that fails here leaves the calls
1609
+ // unsettled, which is what lets a later attempt answer them properly.
1610
+ await this.#invokeToolTransactHook(txCtx, result.callId, this.#hooks.afterToolCallTransact, result);
1460
1611
  }
1461
1612
  });
1462
1613
  this.#messages.push(...results);
@@ -1475,6 +1626,14 @@ export class AgentBase {
1475
1626
  async #appendRecord(ctx, record) {
1476
1627
  await this.#persistence.append(ctx, record);
1477
1628
  }
1629
+ /** Remove deduplication identities for user records a history replacement is deleting. */
1630
+ async #deleteMessageIdentities(ctx, records) {
1631
+ for (const record of records) {
1632
+ if (record.type === "user") {
1633
+ await this.#persistence.deleteValue(ctx, `message.${record.id}`);
1634
+ }
1635
+ }
1636
+ }
1478
1637
  /**
1479
1638
  * A transaction whose pending-state cache unwinds with it.
1480
1639
  */
@@ -1508,7 +1667,7 @@ export class AgentBase {
1508
1667
  content: [{ type: "text", text: `The last turn failed: ${message}` }],
1509
1668
  };
1510
1669
  try {
1511
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1670
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1512
1671
  await this.#appendRecord(lockCtx, { type: "system", message: failure });
1513
1672
  this.#messages.push(failure);
1514
1673
  });
@@ -1521,9 +1680,17 @@ export class AgentBase {
1521
1680
  * Move the oldest queued message — or, in "all" mode, every queued message — into the main
1522
1681
  * context store and the in-memory history. The moves run in one transaction, so a message
1523
1682
  * is never durable in both stores or neither, and memory changes only after the commit.
1524
- */
1525
- async #consumeQueue(queue, mode, prefix) {
1526
- return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1683
+ *
1684
+ * What the consumption has to announce is announced once the lock has been released. A hook
1685
+ * told a message has landed may perfectly well answer by sending another one, and doing that
1686
+ * while this still held the store lock would be the hook waiting for its own caller.
1687
+ */
1688
+ async #consumeQueue(queue, mode, kind) {
1689
+ const prefix = `${kind}.`;
1690
+ /** Filled in once the consumption has committed, and reported after the lock is released. */
1691
+ const accepted = [];
1692
+ let permissionChange;
1693
+ const consumed = await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1527
1694
  if (queue.length === 0)
1528
1695
  return false;
1529
1696
  // The durable queue, not memory, decides what is left to consume after a restart.
@@ -1542,6 +1709,7 @@ export class AgentBase {
1542
1709
  let model = this.#model;
1543
1710
  let effort = this.#effort;
1544
1711
  let serviceTier = this.#serviceTier;
1712
+ let permissionMode = this.#permissionMode;
1545
1713
  let changed = false;
1546
1714
  for (const entry of batch) {
1547
1715
  if (entry.options.provider !== undefined) {
@@ -1560,7 +1728,17 @@ export class AgentBase {
1560
1728
  serviceTier = entry.options.serviceTier;
1561
1729
  changed = true;
1562
1730
  }
1731
+ if (entry.options.permissionMode !== undefined) {
1732
+ permissionMode = entry.options.permissionMode;
1733
+ changed = true;
1734
+ }
1563
1735
  }
1736
+ // The mode the messages make effective, kept apart from the rest because it is the one
1737
+ // setting with hooks of its own: a change is announced, and what a feature concludes
1738
+ // from it commits with the message that carried it.
1739
+ const modeChange = permissionMode === this.#permissionMode
1740
+ ? undefined
1741
+ : { previousMode: this.#permissionMode, mode: permissionMode };
1564
1742
  // A provider or model change is checked against the provider-model compatibility
1565
1743
  // matrix. An incompatible change resets the conversation: the history is erased
1566
1744
  // completely, the old provider session is destroyed, and the `modelChanged` hook
@@ -1610,6 +1788,7 @@ export class AgentBase {
1610
1788
  model,
1611
1789
  effort,
1612
1790
  serviceTier,
1791
+ permissionMode,
1613
1792
  }), committed.signal);
1614
1793
  const changeCtx = withAgentRunKV(withAgentKV(changeLifetime, this.#kv), this.#runKV);
1615
1794
  try {
@@ -1648,6 +1827,7 @@ export class AgentBase {
1648
1827
  await this.#persistence.deleteValue(txCtx, entry.key);
1649
1828
  }
1650
1829
  if (reset) {
1830
+ await this.#deleteMessageIdentities(txCtx, await this.#persistence.load(txCtx));
1651
1831
  await this.#persistence.clearRecords(txCtx);
1652
1832
  // The erased conversation is what the measurement described.
1653
1833
  await this.#persistence.deleteValue(txCtx, "context");
@@ -1661,7 +1841,9 @@ export class AgentBase {
1661
1841
  for (const entry of batch) {
1662
1842
  await this.#appendRecord(txCtx, {
1663
1843
  type: "user",
1844
+ id: entry.id,
1664
1845
  message: entry.message,
1846
+ ...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
1665
1847
  });
1666
1848
  }
1667
1849
  if (changed) {
@@ -1670,13 +1852,38 @@ export class AgentBase {
1670
1852
  ...(model === undefined ? {} : { model }),
1671
1853
  ...(effort === undefined ? {} : { effort }),
1672
1854
  ...(serviceTier === undefined ? {} : { serviceTier }),
1855
+ permissionMode,
1673
1856
  });
1674
1857
  }
1675
1858
  // Consuming a message is precisely the act that makes an inference owed, so
1676
1859
  // the two commit as one. A crash cannot land between them and leave a
1677
1860
  // message in the conversation that nothing remembers having to answer.
1678
1861
  await this.#recordPending(txCtx, { stage: "inference" });
1862
+ // Last, so a hook writing its own account of the consumption sees a transaction
1863
+ // holding all of it. The mode comes before the messages: it is what they were
1864
+ // said under, and a listener recording them wants to know that first.
1865
+ const selection = { provider, model, effort, serviceTier, permissionMode };
1866
+ if (modeChange !== undefined) {
1867
+ await this.#invokeTransactHook(txCtx, selection, this.#hooks.permissionModeChangedTransact, modeChange);
1868
+ }
1869
+ for (const entry of batch) {
1870
+ await this.#invokeTransactHook(txCtx, selection, this.#hooks.messageAcceptedTransact, {
1871
+ id: entry.id,
1872
+ kind,
1873
+ message: entry.message,
1874
+ ...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
1875
+ });
1876
+ }
1679
1877
  });
1878
+ // Committed: from here the messages are part of the conversation, so what has to be
1879
+ // announced about them is decided now and reported once the lock is released.
1880
+ permissionChange = modeChange;
1881
+ accepted.push(...batch.map((entry) => ({
1882
+ id: entry.id,
1883
+ kind,
1884
+ message: entry.message,
1885
+ ...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
1886
+ })));
1680
1887
  queue.splice(0, count);
1681
1888
  if (reset) {
1682
1889
  this.#messages = injected === undefined ? [] : [injected];
@@ -1696,10 +1903,31 @@ export class AgentBase {
1696
1903
  this.#model = model;
1697
1904
  this.#effort = effort;
1698
1905
  this.#serviceTier = serviceTier;
1906
+ this.#permissionMode = permissionMode;
1699
1907
  this.#ctx = this.#deriveCtx();
1700
1908
  }
1701
1909
  return true;
1702
1910
  });
1911
+ // Outside the lock, and on the agent's own context, which now carries whatever these
1912
+ // messages made effective.
1913
+ if (permissionChange !== undefined) {
1914
+ await this.#invokeHook(this.#hooks.permissionModeChanged, permissionChange);
1915
+ }
1916
+ for (const message of accepted) {
1917
+ await this.#invokeHook(this.#hooks.messageAccepted, message);
1918
+ }
1919
+ return consumed;
1920
+ }
1921
+ /**
1922
+ * Call a hook that writes inside the consumption's transaction, on a context carrying the
1923
+ * selection those messages made effective rather than the one they replaced. Its failure is
1924
+ * not contained: it rolls the whole consumption back, leaving the messages queued.
1925
+ */
1926
+ async #invokeTransactHook(txCtx, selection, hook, argument) {
1927
+ if (hook === undefined)
1928
+ return;
1929
+ const hookCtx = withAgentContext(txCtx, { id: this.id, ...selection });
1930
+ await this.#withTransactionalContext(hookCtx, (liveCtx) => hook(liveCtx, argument));
1703
1931
  }
1704
1932
  /**
1705
1933
  * Replace the in-memory state with the durable one. The persistence lock guarantees every
@@ -1708,7 +1936,7 @@ export class AgentBase {
1708
1936
  * not-yet-consumed queues. Consecutive block records reassemble into one assistant message.
1709
1937
  */
1710
1938
  async #loadHistory() {
1711
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1939
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1712
1940
  const records = await this.#persistence.load(lockCtx);
1713
1941
  const last = records[records.length - 1];
1714
1942
  this.#lastRecordType = last?.type;
@@ -1725,7 +1953,17 @@ export class AgentBase {
1725
1953
  this.#contextTokens = measured?.tokens;
1726
1954
  const entry = (key, value) => {
1727
1955
  const envelope = value;
1728
- return { key, message: envelope.message, options: envelope.options ?? {} };
1956
+ if (!Value.Check(cuid2Schema, envelope.id)) {
1957
+ throw new Error(`The queued message under "${key}" has an invalid ID.`);
1958
+ }
1959
+ const metadata = ownAgentMessageMetadata(envelope.metadata);
1960
+ return {
1961
+ key,
1962
+ id: envelope.id,
1963
+ message: envelope.message,
1964
+ ...(metadata === undefined ? {} : { metadata }),
1965
+ options: envelope.options ?? {},
1966
+ };
1729
1967
  };
1730
1968
  this.#steering = steering.map(({ key, value }) => entry(key, value));
1731
1969
  this.#sends = sends.map(({ key, value }) => entry(key, value));
@@ -1738,6 +1976,13 @@ export class AgentBase {
1738
1976
  this.#model = persisted.model;
1739
1977
  this.#effort = persisted.effort;
1740
1978
  this.#serviceTier = persisted.serviceTier;
1979
+ // The permission mode is the one setting whose absence is not a decision: a record
1980
+ // written before any message carried a mode says nothing about it, and a value
1981
+ // that is not a mode at all says nothing either. Both keep the mode the agent was
1982
+ // built with rather than running under something nothing can interpret.
1983
+ if (isAgentPermissionMode(persisted.permissionMode)) {
1984
+ this.#permissionMode = persisted.permissionMode;
1985
+ }
1741
1986
  this.#ctx = this.#deriveCtx();
1742
1987
  }
1743
1988
  this.#pendingTools = pendingTools.map(({ key, value }) => ({
@@ -1774,7 +2019,7 @@ export class AgentBase {
1774
2019
  */
1775
2020
  async #runToolBatch(entries, resume, signal, abortPromise) {
1776
2021
  if (!resume) {
1777
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
2022
+ await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
1778
2023
  for (const entry of entries) {
1779
2024
  await this.#persistence.writeValue(txCtx, entry.key, entry.call);
1780
2025
  }
@@ -1782,6 +2027,11 @@ export class AgentBase {
1782
2027
  // then never find calls owed with no record of a run owing them, nor a run
1783
2028
  // recorded as running tools that were never written.
1784
2029
  await this.#recordPending(txCtx, { stage: "tools" });
2030
+ // Last, so a hook noting a call about to happen sees a transaction holding
2031
+ // the whole batch it belongs to.
2032
+ for (const entry of entries) {
2033
+ await this.#invokeToolTransactHook(txCtx, entry.call.callId, this.#hooks.beforeToolCallTransact, entry.call);
2034
+ }
1785
2035
  }));
1786
2036
  }
1787
2037
  else {
@@ -1801,7 +2051,7 @@ export class AgentBase {
1801
2051
  if (commitFailed)
1802
2052
  return;
1803
2053
  try {
1804
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
2054
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
1805
2055
  while (committed < entries.length) {
1806
2056
  const entry = entries[committed];
1807
2057
  const result = results[committed];
@@ -1817,6 +2067,7 @@ export class AgentBase {
1817
2067
  // stays: that is the tool's state, not the batch's bookkeeping, and
1818
2068
  // an owner may still want to read what a finished call recorded.
1819
2069
  await this.#persistence.deleteValue(txCtx, entry.key);
2070
+ await this.#invokeToolTransactHook(txCtx, entry.call.callId, this.#hooks.afterToolCallTransact, result);
1820
2071
  });
1821
2072
  this.#messages.push(result);
1822
2073
  committed += 1;
@@ -1913,6 +2164,30 @@ export class AgentBase {
1913
2164
  #toolKey(index, callId) {
1914
2165
  return `tool.${String(index).padStart(6, "0")}.${callId}`;
1915
2166
  }
2167
+ /**
2168
+ * The scope one call owns: state persists under its own call ID, never in another call's
2169
+ * scope, and the task context ends where the call was made. The execution and all four tool
2170
+ * hooks share it, so what one of them writes about a call is where the others look for it.
2171
+ */
2172
+ #callScoped(ctx, callId) {
2173
+ return withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call", callId)), this.#runKV.scoped("call", callId)), taskContextBeforeToolCall(this.#messages, callId));
2174
+ }
2175
+ /**
2176
+ * Call a tool hook that writes inside a transaction of its own call's. The lifetime ends with
2177
+ * the callback, so a context kept afterwards cannot outlive the transaction it belongs to.
2178
+ * Its failure is not contained: it rolls that transaction back.
2179
+ */
2180
+ async #invokeToolTransactHook(txCtx, callId, hook, argument) {
2181
+ if (hook === undefined)
2182
+ return;
2183
+ const lifetime = new AbortController();
2184
+ try {
2185
+ await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal), callId), argument);
2186
+ }
2187
+ finally {
2188
+ lifetime.abort();
2189
+ }
2190
+ }
1916
2191
  /**
1917
2192
  * Run one tool call; every failure becomes an error tool result instead of an exception.
1918
2193
  * The context carries the turn's abort signal as its lifetime, so a running tool can
@@ -1942,34 +2217,77 @@ export class AgentBase {
1942
2217
  if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
1943
2218
  return failure(`The arguments for "${call.name}" did not match its schema.`);
1944
2219
  }
2220
+ const callCtx = this.#callScoped(ctx, call.callId);
2221
+ // From here the call is one the two tool hooks bracket: a tool that exists, a call that
2222
+ // finished, and arguments its schema accepts. A call refused before that reaches neither
2223
+ // hook, because there is nothing yet to decide about or to report.
2224
+ let ran = tool;
2225
+ let ranArguments = args;
2226
+ let outcome;
1945
2227
  try {
1946
- // A tool execution persists under its own call ID, never in another call's scope.
1947
- const callCtx = withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call", call.callId)), this.#runKV.scoped("call", call.callId)), taskContextBeforeToolCall(this.#messages, call.callId));
1948
- let executed;
1949
- const execute = () => (executed ??= Promise.resolve().then(async () => await tool.execute(callCtx, args)));
1950
- const result = this.#hooks.aroundToolExecution === undefined
1951
- ? await execute()
1952
- : await this.#hooks.aroundToolExecution(callCtx, {
2228
+ const decision = await this.#hooks.beforeToolCall?.(callCtx, {
2229
+ callId: call.callId,
2230
+ tool,
2231
+ arguments: args,
2232
+ });
2233
+ if (decision?.type === "answer") {
2234
+ // The hook answered the model itself, so the tool never runs and there is no
2235
+ // structured result — only what the model is told.
2236
+ outcome = {
1953
2237
  callId: call.callId,
1954
2238
  tool,
1955
2239
  arguments: args,
1956
- execute,
1957
- });
1958
- if (!Value.Check(tool.returnType, result)) {
1959
- return failure(`Tool "${call.name}" returned an invalid result.`);
2240
+ content: [...decision.content],
2241
+ isError: decision.isError === true,
2242
+ };
2243
+ }
2244
+ else {
2245
+ if (decision?.tool !== undefined)
2246
+ ran = decision.tool;
2247
+ if (decision?.arguments !== undefined)
2248
+ ranArguments = decision.arguments;
2249
+ // An amended call is validated again: the schema that mattered is the one belonging
2250
+ // to the tool that is about to run, on the arguments it is about to receive.
2251
+ if ((ran !== tool || ranArguments !== args) &&
2252
+ ran.parameters !== undefined &&
2253
+ !Value.Check(ran.parameters, ranArguments)) {
2254
+ throw new Error(`The arguments for "${ran.name}" did not match its schema.`);
2255
+ }
2256
+ const runCtx = decision?.permissionMode === undefined
2257
+ ? callCtx
2258
+ : withAgentPermissionMode(callCtx, decision.permissionMode);
2259
+ const result = await ran.execute(runCtx, ranArguments);
2260
+ if (!Value.Check(ran.returnType, result)) {
2261
+ throw new Error(`Tool "${ran.name}" returned an invalid result.`);
2262
+ }
2263
+ outcome = {
2264
+ callId: call.callId,
2265
+ tool: ran,
2266
+ arguments: ranArguments,
2267
+ content: [...ran.toLLM(result)],
2268
+ isError: ran.isError?.(result) === true,
2269
+ result,
2270
+ };
1960
2271
  }
1961
- const content = tool.toLLM(result);
1962
- const isError = tool.isError?.(result) === true;
1963
- return {
1964
- role: "tool",
1965
- callId: call.callId,
1966
- content: [...content],
1967
- ...(isError ? { isError: true } : {}),
1968
- };
1969
2272
  }
1970
2273
  catch (error) {
1971
- return failure(error instanceof Error ? error.message : String(error));
2274
+ outcome = {
2275
+ callId: call.callId,
2276
+ tool: ran,
2277
+ arguments: ranArguments,
2278
+ content: [
2279
+ { type: "text", text: error instanceof Error ? error.message : String(error) },
2280
+ ],
2281
+ isError: true,
2282
+ };
1972
2283
  }
2284
+ await this.#invokeHookOn(callCtx, this.#hooks.afterToolCall, outcome);
2285
+ return {
2286
+ role: "tool",
2287
+ callId: call.callId,
2288
+ content: outcome.content,
2289
+ ...(outcome.isError ? { isError: true } : {}),
2290
+ };
1973
2291
  }
1974
2292
  /**
1975
2293
  * A key that sorts after every entry the queue already holds. The order comes from the store
@@ -2005,7 +2323,7 @@ export class AgentBase {
2005
2323
  const persist = async (event) => {
2006
2324
  if (event === undefined)
2007
2325
  return;
2008
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
2326
+ await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
2009
2327
  if (this.#hooks.onEventTransact === undefined) {
2010
2328
  await this.#appendRecord(lockCtx, { type: "block", block: event.block });
2011
2329
  }
@@ -2030,7 +2348,7 @@ export class AgentBase {
2030
2348
  const next = await Promise.race([iterator.next(), abortPromise]);
2031
2349
  if (next === ABORTED) {
2032
2350
  // Drop the unfinished block and end the turn.
2033
- this.#emit({ type: "done", state: "cancelled" });
2351
+ await this.#emit({ type: "done", state: "cancelled" });
2034
2352
  return { content: persisted, state: "cancelled" };
2035
2353
  }
2036
2354
  if (next.done === true) {
@@ -2038,7 +2356,7 @@ export class AgentBase {
2038
2356
  break;
2039
2357
  }
2040
2358
  const event = next.value;
2041
- this.#emit(event);
2359
+ await this.#emit(event);
2042
2360
  switch (event.type) {
2043
2361
  case "text_start":
2044
2362
  content.push({ type: "text", text: "" });
@@ -2189,9 +2507,9 @@ export class AgentBase {
2189
2507
  return this.#session;
2190
2508
  }
2191
2509
  /** Report one stream event to the hooks. Hooks observe the stream; they never fail a run. */
2192
- #emit(event) {
2510
+ async #emit(event) {
2193
2511
  try {
2194
- this.#hooks.onEvent?.(this.#ctx, event);
2512
+ await this.#hooks.onEvent?.(this.#ctx, event);
2195
2513
  }
2196
2514
  catch {
2197
2515
  // Hooks observe the stream; they never fail a run.