@slopus/happy-agent-base 0.0.3 → 0.0.5

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.
package/dist/AgentBase.js CHANGED
@@ -2,10 +2,11 @@ import { areProviderModelsCompatible } from "@slopus/happy-providers";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { Value } from "@sinclair/typebox/value";
4
4
  import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
5
- import { withAgentContext, withAgentKV, withAgentRunKV } from "./AgentContexts.js";
5
+ import { withAgentContext, withAgentKV, withAgentPermissionMode, withAgentRunKV, } from "./AgentContexts.js";
6
6
  import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
7
7
  import { AgentKV } from "./AgentKV.js";
8
8
  import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
9
+ import { DEFAULT_AGENT_PERMISSION_MODE, isAgentPermissionMode, } from "./AgentPermissionMode.js";
9
10
  import { AgentProviders } from "./AgentProviders.js";
10
11
  /** Race winner when an abort interrupts a wait on the stream or a running tool. */
11
12
  const ABORTED = Symbol("aborted");
@@ -103,6 +104,20 @@ const INSIDE_CLOSE_REPORT_MS = 15;
103
104
  * capability released when the hook returns, so it cannot be retained to bypass the lock later.
104
105
  * A failing handoff rejects an incompatible switch outright rather than costing the history.
105
106
  *
107
+ * ## Permission modes
108
+ *
109
+ * How much of the machine the agent may touch travels with its messages, exactly like its model,
110
+ * and takes effect when the message is consumed rather than when it is queued: a response and the
111
+ * tools it dispatched are already running under the mode they were started with, and are left to
112
+ * finish under it. The mode is durable, so a restart resumes in the mode the conversation reached,
113
+ * and it is carried on every context the agent derives, so a hook or a tool reads what it is
114
+ * running under rather than being told.
115
+ *
116
+ * The loop enforces nothing. It has no idea what any particular tool touches, and a runtime that
117
+ * guessed would be wrong about tools it has never seen. Enforcement belongs to the features and
118
+ * tools that do know; the loop's whole part is to carry the mode, make its changes durable, and
119
+ * report them.
120
+ *
106
121
  * ## Recovery
107
122
  *
108
123
  * Whether a restart owes a response is decided by the last durable record: a consumed message, a
@@ -200,6 +215,11 @@ export class AgentBase {
200
215
  #effort;
201
216
  /** The service tier in force. */
202
217
  #serviceTier;
218
+ /**
219
+ * How much of the machine the agent may touch. Durable, so a restart resumes in the mode the
220
+ * conversation reached, and carried on every context the agent derives.
221
+ */
222
+ #permissionMode;
203
223
  /** The single set of hooks the run is observed by and its configuration extended from. */
204
224
  #hooks;
205
225
  /**
@@ -401,6 +421,7 @@ export class AgentBase {
401
421
  this.#model = options.model;
402
422
  this.#effort = options.effort;
403
423
  this.#serviceTier = options.serviceTier;
424
+ this.#permissionMode = options.permissionMode ?? DEFAULT_AGENT_PERMISSION_MODE;
404
425
  this.#kv = new AgentKV(this.#persistence, `kv.${options.id}.`);
405
426
  this.#runKV = this.#kv.scoped("run");
406
427
  // Everything the agent does — hooks and tool executions included — runs on a context
@@ -415,14 +436,19 @@ export class AgentBase {
415
436
  * the selection changes.
416
437
  */
417
438
  #deriveCtx() {
418
- const ctx = withAgentContext(this.#baseCtx, {
439
+ const ctx = withAgentContext(this.#baseCtx, this.#selection());
440
+ return withAgentRunKV(withAgentKV(ctx, this.#kv), this.#runKV);
441
+ }
442
+ /** Everything about what the agent is currently running on, as one value to carry. */
443
+ #selection() {
444
+ return {
419
445
  id: this.id,
420
446
  provider: this.#providerId,
421
447
  model: this.#model,
422
448
  effort: this.#effort,
423
449
  serviceTier: this.#serviceTier,
424
- });
425
- return withAgentRunKV(withAgentKV(ctx, this.#kv), this.#runKV);
450
+ permissionMode: this.#permissionMode,
451
+ };
426
452
  }
427
453
  /**
428
454
  * Whether the agent has anything left to do. This is the only thing about an agent's state
@@ -439,9 +465,9 @@ export class AgentBase {
439
465
  * whatever else that transaction is writing, which is how a consumed message and the
440
466
  * inference it owes become durable as one fact rather than two.
441
467
  */
442
- async #recordPending(ctx, pending) {
468
+ async #recordPending(ctx, pending, force = false) {
443
469
  const serialized = deterministicStringify(pending);
444
- if (this.#pendingWritten === serialized)
470
+ if (!force && this.#pendingWritten === serialized)
445
471
  return;
446
472
  await this.#persistence.writeValue(ctx, AGENT_BASE_PENDING_KEY, pending);
447
473
  this.#pending = pending;
@@ -455,22 +481,38 @@ export class AgentBase {
455
481
  * is doing, and a run interrupted by a dead process would be indistinguishable from the one
456
482
  * starting here.
457
483
  */
458
- async #enterStage(stage) {
484
+ async #enterStage(stage, transact) {
459
485
  const pending = { stage };
460
- if (deterministicStringify(pending) === this.#pendingWritten && this.#inheritedRead)
461
- return;
486
+ if (transact === undefined &&
487
+ deterministicStringify(pending) === this.#pendingWritten &&
488
+ this.#inheritedRead) {
489
+ return undefined;
490
+ }
462
491
  try {
463
- await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
492
+ return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
464
493
  if (!this.#inheritedRead) {
465
494
  this.#inheritedRead = true;
466
495
  this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
467
496
  }
468
- await this.#recordPending(lockCtx, pending);
497
+ if (transact === undefined) {
498
+ await this.#recordPending(lockCtx, pending);
499
+ return undefined;
500
+ }
501
+ return await this.#recordTransaction(lockCtx, async (txCtx) => {
502
+ // The state is staged first. The callback then writes against this exact
503
+ // transaction, so neither its conclusion nor the state it observed can land
504
+ // without the other.
505
+ await this.#recordPending(txCtx, pending, true);
506
+ return await this.#withTransactionalContext(txCtx, transact);
507
+ });
469
508
  });
470
509
  }
471
- catch {
510
+ catch (error) {
511
+ if (transact !== undefined)
512
+ throw error;
472
513
  // Losing the record costs recovery precision, never the work itself: the turn is
473
514
  // already running and will answer whatever it was going to answer.
515
+ return undefined;
474
516
  }
475
517
  }
476
518
  /**
@@ -901,7 +943,7 @@ export class AgentBase {
901
943
  // crash could interrupt. What it records is refined as the run reaches each stage;
902
944
  // what matters at this point is that the record exists at all, since its absence is
903
945
  // what a later process reads as an agent that finished.
904
- await this.#enterStage("inference");
946
+ await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact);
905
947
  await this.#invokeHook(this.#hooks.beforeAgentLoop);
906
948
  do {
907
949
  this.#turnAborted = false;
@@ -930,20 +972,29 @@ export class AgentBase {
930
972
  });
931
973
  break;
932
974
  }
933
- await this.#applyActions(this.#hooks.beforeTurn, abort.signal, {
975
+ const turnStart = {
934
976
  contextTokens: this.#contextTokens,
935
- });
977
+ };
978
+ await this.#enterStage("inference", this.#hooks.beforeTurnTransact === undefined
979
+ ? undefined
980
+ : (hookCtx) => this.#hooks.beforeTurnTransact?.(hookCtx, turnStart));
981
+ await this.#applyActions(this.#hooks.beforeTurn, abort.signal, turnStart);
936
982
  await this.#runInference(abort);
937
- await this.#applyActions(this.#hooks.afterTurn, abort.signal, {
983
+ const turn = {
938
984
  contextTokens: this.#contextTokens,
939
985
  aborted: this.#turnAborted,
940
- });
986
+ };
987
+ await this.#enterStage("inference", this.#hooks.afterTurnTransact === undefined
988
+ ? undefined
989
+ : (hookCtx) => this.#hooks.afterTurnTransact?.(hookCtx, turn));
990
+ await this.#applyActions(this.#hooks.afterTurn, abort.signal, turn);
941
991
  if (!this.#turnRequested || this.#closed)
942
992
  break;
943
993
  // Each turn cancels on its own scope. Reopening it here rather than at the top
944
994
  // keeps the run's first turn under the scope its opening hook already ran in.
945
995
  abort = this.#openAbortScope();
946
996
  } while (true);
997
+ await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact);
947
998
  await this.#applyActions(this.#hooks.afterAgentLoop, abort.signal);
948
999
  } while (this.#turnRequested && !this.#closed);
949
1000
  // Nothing is asked for any more, so the outstanding work is erased. That erasure is what
@@ -960,7 +1011,7 @@ export class AgentBase {
960
1011
  */
961
1012
  async #settleDurably() {
962
1013
  try {
963
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#persistence.transaction(lockCtx, async (txCtx) => {
1014
+ await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
964
1015
  await this.#clearPending(txCtx);
965
1016
  await this.#invokeTransactionalSettle(txCtx);
966
1017
  // The run store is erased last, so a settling hook can still read what the
@@ -985,15 +1036,7 @@ export class AgentBase {
985
1036
  const hook = this.#hooks.afterAgentSettledTransact;
986
1037
  if (hook === undefined)
987
1038
  return;
988
- const committed = new AbortController();
989
- try {
990
- const liveCtx = withLifetime(insideTurn.set(txCtx, []), committed.signal);
991
- const hookCtx = withAgentKV(liveCtx, this.#kv);
992
- await hook(withAgentRunKV(hookCtx, this.#runKV));
993
- }
994
- finally {
995
- committed.abort();
996
- }
1039
+ await this.#withTransactionalContext(insideTurn.set(txCtx, []), hook);
997
1040
  }
998
1041
  /**
999
1042
  * Erase everything the run wrote about itself, inside the transaction that settles the agent:
@@ -1044,6 +1087,21 @@ export class AgentBase {
1044
1087
  async #invokeHook(hook, ...args) {
1045
1088
  await this.#invokeHookOn(this.#ctx, hook, ...args);
1046
1089
  }
1090
+ /**
1091
+ * Lend a hook the transaction's context and feature stores for exactly one callback. Keeping
1092
+ * the context after the callback cannot leak a transaction past its commit.
1093
+ */
1094
+ async #withTransactionalContext(txCtx, work) {
1095
+ const lifetime = new AbortController();
1096
+ try {
1097
+ const liveCtx = withLifetime(txCtx, lifetime.signal);
1098
+ const hookCtx = withAgentKV(liveCtx, this.#kv);
1099
+ return await work(withAgentRunKV(hookCtx, this.#runKV));
1100
+ }
1101
+ finally {
1102
+ lifetime.abort();
1103
+ }
1104
+ }
1047
1105
  /**
1048
1106
  * Ask a hook what to do next, on a scope that may be cancelled while the hook is still
1049
1107
  * thinking. An abort owns the whole of the turn it cancelled, including the answer of a hook
@@ -1177,9 +1235,9 @@ export class AgentBase {
1177
1235
  this.#emit({ type: "done", state: "cancelled" });
1178
1236
  break;
1179
1237
  }
1180
- let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering.");
1238
+ let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering");
1181
1239
  if (!injected && !needsInference) {
1182
- injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send.");
1240
+ injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send");
1183
1241
  }
1184
1242
  // Nothing to answer — a start() on an idle history, or the queues ran dry.
1185
1243
  if (!injected && !needsInference)
@@ -1193,6 +1251,7 @@ export class AgentBase {
1193
1251
  // either.
1194
1252
  if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
1195
1253
  continue;
1254
+ await this.#enterStage("inference", this.#hooks.beforeInferenceTransact);
1196
1255
  await this.#invokeHook(this.#hooks.beforeInference);
1197
1256
  const stream = session.run(this.#ctx, {
1198
1257
  context: {
@@ -1206,14 +1265,21 @@ export class AgentBase {
1206
1265
  const { content, state, errorMessage, tokens } = await this.#collect(stream, abortPromise);
1207
1266
  // A cancelled or failed response measures nothing, so the conversation keeps
1208
1267
  // the last real measurement instead of forgetting how large it had become.
1209
- if (tokens !== undefined) {
1210
- await this.#recordContextTokens(tokens.input + tokens.output);
1211
- }
1212
- await this.#invokeHook(this.#hooks.afterInference, {
1268
+ const inference = {
1213
1269
  state,
1214
1270
  tokens,
1215
1271
  ...(errorMessage === undefined ? {} : { errorMessage }),
1216
- });
1272
+ };
1273
+ const afterInferenceTransact = this.#hooks.afterInferenceTransact === undefined
1274
+ ? undefined
1275
+ : (hookCtx) => this.#hooks.afterInferenceTransact?.(hookCtx, inference);
1276
+ if (tokens === undefined) {
1277
+ await this.#enterStage("inference", afterInferenceTransact);
1278
+ }
1279
+ else {
1280
+ await this.#recordContextTokens(tokens.input + tokens.output, afterInferenceTransact);
1281
+ }
1282
+ await this.#invokeHook(this.#hooks.afterInference, inference);
1217
1283
  if (content.length > 0) {
1218
1284
  this.#messages.push({ role: "assistant", content });
1219
1285
  }
@@ -1305,15 +1371,31 @@ export class AgentBase {
1305
1371
  * lets a restarted agent keep knowing how large the conversation is without inferring it;
1306
1372
  * a failed write costs only that knowledge and never the response that produced it.
1307
1373
  */
1308
- async #recordContextTokens(tokens) {
1374
+ async #recordContextTokens(tokens, transact) {
1375
+ const previousTokens = this.#contextTokens;
1309
1376
  this.#contextTokens = tokens;
1310
1377
  try {
1311
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => tokens === undefined
1312
- ? this.#persistence.deleteValue(lockCtx, "context")
1313
- : this.#persistence.writeValue(lockCtx, "context", { tokens }));
1378
+ await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1379
+ const write = (writeCtx) => tokens === undefined
1380
+ ? this.#persistence.deleteValue(writeCtx, "context")
1381
+ : this.#persistence.writeValue(writeCtx, "context", { tokens });
1382
+ if (transact === undefined) {
1383
+ await write(lockCtx);
1384
+ return;
1385
+ }
1386
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
1387
+ await write(txCtx);
1388
+ await this.#withTransactionalContext(txCtx, transact);
1389
+ });
1390
+ });
1314
1391
  }
1315
- catch {
1316
- // A measurement is not worth failing a turn over; memory still carries it.
1392
+ catch (error) {
1393
+ // The prior committed measurement stays authoritative in memory. A transactional
1394
+ // observer is part of the same durable conclusion and therefore fails the turn when
1395
+ // that conclusion cannot commit; an ordinary best-effort measurement still does not.
1396
+ this.#contextTokens = previousTokens;
1397
+ if (transact !== undefined)
1398
+ throw error;
1317
1399
  }
1318
1400
  }
1319
1401
  /**
@@ -1401,6 +1483,10 @@ export class AgentBase {
1401
1483
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1402
1484
  for (const result of results) {
1403
1485
  await this.#appendRecord(txCtx, { type: "tool", message: result });
1486
+ // A result the conversation records is a result the hook sees, however
1487
+ // little of a run produced it. A hook that fails here leaves the calls
1488
+ // unsettled, which is what lets a later attempt answer them properly.
1489
+ await this.#invokeToolTransactHook(txCtx, result.callId, this.#hooks.afterToolCallTransact, result);
1404
1490
  }
1405
1491
  });
1406
1492
  this.#messages.push(...results);
@@ -1465,9 +1551,17 @@ export class AgentBase {
1465
1551
  * Move the oldest queued message — or, in "all" mode, every queued message — into the main
1466
1552
  * context store and the in-memory history. The moves run in one transaction, so a message
1467
1553
  * is never durable in both stores or neither, and memory changes only after the commit.
1468
- */
1469
- async #consumeQueue(queue, mode, prefix) {
1470
- return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1554
+ *
1555
+ * What the consumption has to announce is announced once the lock has been released. A hook
1556
+ * told a message has landed may perfectly well answer by sending another one, and doing that
1557
+ * while this still held the store lock would be the hook waiting for its own caller.
1558
+ */
1559
+ async #consumeQueue(queue, mode, kind) {
1560
+ const prefix = `${kind}.`;
1561
+ /** Filled in once the consumption has committed, and reported after the lock is released. */
1562
+ const accepted = [];
1563
+ let permissionChange;
1564
+ const consumed = await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
1471
1565
  if (queue.length === 0)
1472
1566
  return false;
1473
1567
  // The durable queue, not memory, decides what is left to consume after a restart.
@@ -1486,6 +1580,7 @@ export class AgentBase {
1486
1580
  let model = this.#model;
1487
1581
  let effort = this.#effort;
1488
1582
  let serviceTier = this.#serviceTier;
1583
+ let permissionMode = this.#permissionMode;
1489
1584
  let changed = false;
1490
1585
  for (const entry of batch) {
1491
1586
  if (entry.options.provider !== undefined) {
@@ -1504,7 +1599,17 @@ export class AgentBase {
1504
1599
  serviceTier = entry.options.serviceTier;
1505
1600
  changed = true;
1506
1601
  }
1602
+ if (entry.options.permissionMode !== undefined) {
1603
+ permissionMode = entry.options.permissionMode;
1604
+ changed = true;
1605
+ }
1507
1606
  }
1607
+ // The mode the messages make effective, kept apart from the rest because it is the one
1608
+ // setting with hooks of its own: a change is announced, and what a feature concludes
1609
+ // from it commits with the message that carried it.
1610
+ const modeChange = permissionMode === this.#permissionMode
1611
+ ? undefined
1612
+ : { previousMode: this.#permissionMode, mode: permissionMode };
1508
1613
  // A provider or model change is checked against the provider-model compatibility
1509
1614
  // matrix. An incompatible change resets the conversation: the history is erased
1510
1615
  // completely, the old provider session is destroyed, and the `modelChanged` hook
@@ -1554,6 +1659,7 @@ export class AgentBase {
1554
1659
  model,
1555
1660
  effort,
1556
1661
  serviceTier,
1662
+ permissionMode,
1557
1663
  }), committed.signal);
1558
1664
  const changeCtx = withAgentRunKV(withAgentKV(changeLifetime, this.#kv), this.#runKV);
1559
1665
  try {
@@ -1614,13 +1720,28 @@ export class AgentBase {
1614
1720
  ...(model === undefined ? {} : { model }),
1615
1721
  ...(effort === undefined ? {} : { effort }),
1616
1722
  ...(serviceTier === undefined ? {} : { serviceTier }),
1723
+ permissionMode,
1617
1724
  });
1618
1725
  }
1619
1726
  // Consuming a message is precisely the act that makes an inference owed, so
1620
1727
  // the two commit as one. A crash cannot land between them and leave a
1621
1728
  // message in the conversation that nothing remembers having to answer.
1622
1729
  await this.#recordPending(txCtx, { stage: "inference" });
1730
+ // Last, so a hook writing its own account of the consumption sees a transaction
1731
+ // holding all of it. The mode comes before the messages: it is what they were
1732
+ // said under, and a listener recording them wants to know that first.
1733
+ const selection = { provider, model, effort, serviceTier, permissionMode };
1734
+ if (modeChange !== undefined) {
1735
+ await this.#invokeTransactHook(txCtx, selection, this.#hooks.permissionModeChangedTransact, modeChange);
1736
+ }
1737
+ for (const entry of batch) {
1738
+ await this.#invokeTransactHook(txCtx, selection, this.#hooks.messageAcceptedTransact, { kind, message: entry.message });
1739
+ }
1623
1740
  });
1741
+ // Committed: from here the messages are part of the conversation, so what has to be
1742
+ // announced about them is decided now and reported once the lock is released.
1743
+ permissionChange = modeChange;
1744
+ accepted.push(...batch.map((entry) => ({ kind, message: entry.message })));
1624
1745
  queue.splice(0, count);
1625
1746
  if (reset) {
1626
1747
  this.#messages = injected === undefined ? [] : [injected];
@@ -1640,10 +1761,31 @@ export class AgentBase {
1640
1761
  this.#model = model;
1641
1762
  this.#effort = effort;
1642
1763
  this.#serviceTier = serviceTier;
1764
+ this.#permissionMode = permissionMode;
1643
1765
  this.#ctx = this.#deriveCtx();
1644
1766
  }
1645
1767
  return true;
1646
1768
  });
1769
+ // Outside the lock, and on the agent's own context, which now carries whatever these
1770
+ // messages made effective.
1771
+ if (permissionChange !== undefined) {
1772
+ await this.#invokeHook(this.#hooks.permissionModeChanged, permissionChange);
1773
+ }
1774
+ for (const message of accepted) {
1775
+ await this.#invokeHook(this.#hooks.messageAccepted, message);
1776
+ }
1777
+ return consumed;
1778
+ }
1779
+ /**
1780
+ * Call a hook that writes inside the consumption's transaction, on a context carrying the
1781
+ * selection those messages made effective rather than the one they replaced. Its failure is
1782
+ * not contained: it rolls the whole consumption back, leaving the messages queued.
1783
+ */
1784
+ async #invokeTransactHook(txCtx, selection, hook, argument) {
1785
+ if (hook === undefined)
1786
+ return;
1787
+ const hookCtx = withAgentContext(txCtx, { id: this.id, ...selection });
1788
+ await this.#withTransactionalContext(hookCtx, (liveCtx) => hook(liveCtx, argument));
1647
1789
  }
1648
1790
  /**
1649
1791
  * Replace the in-memory state with the durable one. The persistence lock guarantees every
@@ -1682,6 +1824,13 @@ export class AgentBase {
1682
1824
  this.#model = persisted.model;
1683
1825
  this.#effort = persisted.effort;
1684
1826
  this.#serviceTier = persisted.serviceTier;
1827
+ // The permission mode is the one setting whose absence is not a decision: a record
1828
+ // written before any message carried a mode says nothing about it, and a value
1829
+ // that is not a mode at all says nothing either. Both keep the mode the agent was
1830
+ // built with rather than running under something nothing can interpret.
1831
+ if (isAgentPermissionMode(persisted.permissionMode)) {
1832
+ this.#permissionMode = persisted.permissionMode;
1833
+ }
1685
1834
  this.#ctx = this.#deriveCtx();
1686
1835
  }
1687
1836
  this.#pendingTools = pendingTools.map(({ key, value }) => ({
@@ -1726,6 +1875,11 @@ export class AgentBase {
1726
1875
  // then never find calls owed with no record of a run owing them, nor a run
1727
1876
  // recorded as running tools that were never written.
1728
1877
  await this.#recordPending(txCtx, { stage: "tools" });
1878
+ // Last, so a hook noting a call about to happen sees a transaction holding
1879
+ // the whole batch it belongs to.
1880
+ for (const entry of entries) {
1881
+ await this.#invokeToolTransactHook(txCtx, entry.call.callId, this.#hooks.beforeToolCallTransact, entry.call);
1882
+ }
1729
1883
  }));
1730
1884
  }
1731
1885
  else {
@@ -1761,6 +1915,7 @@ export class AgentBase {
1761
1915
  // stays: that is the tool's state, not the batch's bookkeeping, and
1762
1916
  // an owner may still want to read what a finished call recorded.
1763
1917
  await this.#persistence.deleteValue(txCtx, entry.key);
1918
+ await this.#invokeToolTransactHook(txCtx, entry.call.callId, this.#hooks.afterToolCallTransact, result);
1764
1919
  });
1765
1920
  this.#messages.push(result);
1766
1921
  committed += 1;
@@ -1857,6 +2012,30 @@ export class AgentBase {
1857
2012
  #toolKey(index, callId) {
1858
2013
  return `tool.${String(index).padStart(6, "0")}.${callId}`;
1859
2014
  }
2015
+ /**
2016
+ * The scope one call owns: state persists under its own call ID, never in another call's
2017
+ * scope, and the task context ends where the call was made. The execution and all four tool
2018
+ * hooks share it, so what one of them writes about a call is where the others look for it.
2019
+ */
2020
+ #callScoped(ctx, callId) {
2021
+ return withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call", callId)), this.#runKV.scoped("call", callId)), taskContextBeforeToolCall(this.#messages, callId));
2022
+ }
2023
+ /**
2024
+ * Call a tool hook that writes inside a transaction of its own call's. The lifetime ends with
2025
+ * the callback, so a context kept afterwards cannot outlive the transaction it belongs to.
2026
+ * Its failure is not contained: it rolls that transaction back.
2027
+ */
2028
+ async #invokeToolTransactHook(txCtx, callId, hook, argument) {
2029
+ if (hook === undefined)
2030
+ return;
2031
+ const lifetime = new AbortController();
2032
+ try {
2033
+ await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal), callId), argument);
2034
+ }
2035
+ finally {
2036
+ lifetime.abort();
2037
+ }
2038
+ }
1860
2039
  /**
1861
2040
  * Run one tool call; every failure becomes an error tool result instead of an exception.
1862
2041
  * The context carries the turn's abort signal as its lifetime, so a running tool can
@@ -1886,34 +2065,77 @@ export class AgentBase {
1886
2065
  if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
1887
2066
  return failure(`The arguments for "${call.name}" did not match its schema.`);
1888
2067
  }
2068
+ const callCtx = this.#callScoped(ctx, call.callId);
2069
+ // From here the call is one the two tool hooks bracket: a tool that exists, a call that
2070
+ // finished, and arguments its schema accepts. A call refused before that reaches neither
2071
+ // hook, because there is nothing yet to decide about or to report.
2072
+ let ran = tool;
2073
+ let ranArguments = args;
2074
+ let outcome;
1889
2075
  try {
1890
- // A tool execution persists under its own call ID, never in another call's scope.
1891
- const callCtx = withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call", call.callId)), this.#runKV.scoped("call", call.callId)), taskContextBeforeToolCall(this.#messages, call.callId));
1892
- let executed;
1893
- const execute = () => (executed ??= Promise.resolve().then(async () => await tool.execute(callCtx, args)));
1894
- const result = this.#hooks.aroundToolExecution === undefined
1895
- ? await execute()
1896
- : await this.#hooks.aroundToolExecution(callCtx, {
2076
+ const decision = await this.#hooks.beforeToolCall?.(callCtx, {
2077
+ callId: call.callId,
2078
+ tool,
2079
+ arguments: args,
2080
+ });
2081
+ if (decision?.type === "answer") {
2082
+ // The hook answered the model itself, so the tool never runs and there is no
2083
+ // structured result — only what the model is told.
2084
+ outcome = {
1897
2085
  callId: call.callId,
1898
2086
  tool,
1899
2087
  arguments: args,
1900
- execute,
1901
- });
1902
- if (!Value.Check(tool.returnType, result)) {
1903
- return failure(`Tool "${call.name}" returned an invalid result.`);
2088
+ content: [...decision.content],
2089
+ isError: decision.isError === true,
2090
+ };
2091
+ }
2092
+ else {
2093
+ if (decision?.tool !== undefined)
2094
+ ran = decision.tool;
2095
+ if (decision?.arguments !== undefined)
2096
+ ranArguments = decision.arguments;
2097
+ // An amended call is validated again: the schema that mattered is the one belonging
2098
+ // to the tool that is about to run, on the arguments it is about to receive.
2099
+ if ((ran !== tool || ranArguments !== args) &&
2100
+ ran.parameters !== undefined &&
2101
+ !Value.Check(ran.parameters, ranArguments)) {
2102
+ throw new Error(`The arguments for "${ran.name}" did not match its schema.`);
2103
+ }
2104
+ const runCtx = decision?.permissionMode === undefined
2105
+ ? callCtx
2106
+ : withAgentPermissionMode(callCtx, decision.permissionMode);
2107
+ const result = await ran.execute(runCtx, ranArguments);
2108
+ if (!Value.Check(ran.returnType, result)) {
2109
+ throw new Error(`Tool "${ran.name}" returned an invalid result.`);
2110
+ }
2111
+ outcome = {
2112
+ callId: call.callId,
2113
+ tool: ran,
2114
+ arguments: ranArguments,
2115
+ content: [...ran.toLLM(result)],
2116
+ isError: ran.isError?.(result) === true,
2117
+ result,
2118
+ };
1904
2119
  }
1905
- const content = tool.toLLM(result);
1906
- const isError = tool.isError?.(result) === true;
1907
- return {
1908
- role: "tool",
1909
- callId: call.callId,
1910
- content: [...content],
1911
- ...(isError ? { isError: true } : {}),
1912
- };
1913
2120
  }
1914
2121
  catch (error) {
1915
- return failure(error instanceof Error ? error.message : String(error));
2122
+ outcome = {
2123
+ callId: call.callId,
2124
+ tool: ran,
2125
+ arguments: ranArguments,
2126
+ content: [
2127
+ { type: "text", text: error instanceof Error ? error.message : String(error) },
2128
+ ],
2129
+ isError: true,
2130
+ };
1916
2131
  }
2132
+ await this.#invokeHookOn(callCtx, this.#hooks.afterToolCall, outcome);
2133
+ return {
2134
+ role: "tool",
2135
+ callId: call.callId,
2136
+ content: outcome.content,
2137
+ ...(outcome.isError ? { isError: true } : {}),
2138
+ };
1917
2139
  }
1918
2140
  /**
1919
2141
  * A key that sorts after every entry the queue already holds. The order comes from the store
@@ -1946,11 +2168,21 @@ export class AgentBase {
1946
2168
  // in-memory assistant message never diverges from what a reload would rebuild.
1947
2169
  const persisted = [];
1948
2170
  const toolCallIndexes = new Map();
1949
- const persist = async (block) => {
1950
- if (block === undefined)
2171
+ const persist = async (event) => {
2172
+ if (event === undefined)
1951
2173
  return;
1952
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#appendRecord(lockCtx, { type: "block", block }));
1953
- persisted.push(block);
2174
+ await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
2175
+ if (this.#hooks.onEventTransact === undefined) {
2176
+ await this.#appendRecord(lockCtx, { type: "block", block: event.block });
2177
+ }
2178
+ else {
2179
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
2180
+ await this.#appendRecord(txCtx, { type: "block", block: event.block });
2181
+ await this.#withTransactionalContext(txCtx, (hookCtx) => this.#hooks.onEventTransact?.(hookCtx, event));
2182
+ });
2183
+ }
2184
+ });
2185
+ persisted.push(event.block);
1954
2186
  };
1955
2187
  const iterator = stream[Symbol.asyncIterator]();
1956
2188
  // A response usually ends before its stream does — at the done event, or at an abort —
@@ -1989,7 +2221,7 @@ export class AgentBase {
1989
2221
  }
1990
2222
  case "text_end": {
1991
2223
  const last = content[content.length - 1];
1992
- await persist(last?.type === "text" ? last : undefined);
2224
+ await persist(last?.type === "text" ? { ...event, block: last } : undefined);
1993
2225
  break;
1994
2226
  }
1995
2227
  case "reasoning_start":
@@ -2015,7 +2247,7 @@ export class AgentBase {
2015
2247
  : { reasoning: event.reasoning }),
2016
2248
  };
2017
2249
  content[content.length - 1] = finished;
2018
- await persist(finished);
2250
+ await persist({ ...event, block: finished });
2019
2251
  }
2020
2252
  break;
2021
2253
  }
@@ -2045,7 +2277,7 @@ export class AgentBase {
2045
2277
  : { incomplete: event.incomplete }),
2046
2278
  };
2047
2279
  content[index] = finished;
2048
- await persist(finished);
2280
+ await persist({ ...event, block: finished });
2049
2281
  }
2050
2282
  break;
2051
2283
  }