@slopus/happy-agent-base 0.0.4 → 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
@@ -1209,9 +1235,9 @@ export class AgentBase {
1209
1235
  this.#emit({ type: "done", state: "cancelled" });
1210
1236
  break;
1211
1237
  }
1212
- let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering.");
1238
+ let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering");
1213
1239
  if (!injected && !needsInference) {
1214
- injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send.");
1240
+ injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send");
1215
1241
  }
1216
1242
  // Nothing to answer — a start() on an idle history, or the queues ran dry.
1217
1243
  if (!injected && !needsInference)
@@ -1457,6 +1483,10 @@ export class AgentBase {
1457
1483
  await this.#recordTransaction(lockCtx, async (txCtx) => {
1458
1484
  for (const result of results) {
1459
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);
1460
1490
  }
1461
1491
  });
1462
1492
  this.#messages.push(...results);
@@ -1521,9 +1551,17 @@ export class AgentBase {
1521
1551
  * Move the oldest queued message — or, in "all" mode, every queued message — into the main
1522
1552
  * context store and the in-memory history. The moves run in one transaction, so a message
1523
1553
  * 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) => {
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) => {
1527
1565
  if (queue.length === 0)
1528
1566
  return false;
1529
1567
  // The durable queue, not memory, decides what is left to consume after a restart.
@@ -1542,6 +1580,7 @@ export class AgentBase {
1542
1580
  let model = this.#model;
1543
1581
  let effort = this.#effort;
1544
1582
  let serviceTier = this.#serviceTier;
1583
+ let permissionMode = this.#permissionMode;
1545
1584
  let changed = false;
1546
1585
  for (const entry of batch) {
1547
1586
  if (entry.options.provider !== undefined) {
@@ -1560,7 +1599,17 @@ export class AgentBase {
1560
1599
  serviceTier = entry.options.serviceTier;
1561
1600
  changed = true;
1562
1601
  }
1602
+ if (entry.options.permissionMode !== undefined) {
1603
+ permissionMode = entry.options.permissionMode;
1604
+ changed = true;
1605
+ }
1563
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 };
1564
1613
  // A provider or model change is checked against the provider-model compatibility
1565
1614
  // matrix. An incompatible change resets the conversation: the history is erased
1566
1615
  // completely, the old provider session is destroyed, and the `modelChanged` hook
@@ -1610,6 +1659,7 @@ export class AgentBase {
1610
1659
  model,
1611
1660
  effort,
1612
1661
  serviceTier,
1662
+ permissionMode,
1613
1663
  }), committed.signal);
1614
1664
  const changeCtx = withAgentRunKV(withAgentKV(changeLifetime, this.#kv), this.#runKV);
1615
1665
  try {
@@ -1670,13 +1720,28 @@ export class AgentBase {
1670
1720
  ...(model === undefined ? {} : { model }),
1671
1721
  ...(effort === undefined ? {} : { effort }),
1672
1722
  ...(serviceTier === undefined ? {} : { serviceTier }),
1723
+ permissionMode,
1673
1724
  });
1674
1725
  }
1675
1726
  // Consuming a message is precisely the act that makes an inference owed, so
1676
1727
  // the two commit as one. A crash cannot land between them and leave a
1677
1728
  // message in the conversation that nothing remembers having to answer.
1678
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
+ }
1679
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 })));
1680
1745
  queue.splice(0, count);
1681
1746
  if (reset) {
1682
1747
  this.#messages = injected === undefined ? [] : [injected];
@@ -1696,10 +1761,31 @@ export class AgentBase {
1696
1761
  this.#model = model;
1697
1762
  this.#effort = effort;
1698
1763
  this.#serviceTier = serviceTier;
1764
+ this.#permissionMode = permissionMode;
1699
1765
  this.#ctx = this.#deriveCtx();
1700
1766
  }
1701
1767
  return true;
1702
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));
1703
1789
  }
1704
1790
  /**
1705
1791
  * Replace the in-memory state with the durable one. The persistence lock guarantees every
@@ -1738,6 +1824,13 @@ export class AgentBase {
1738
1824
  this.#model = persisted.model;
1739
1825
  this.#effort = persisted.effort;
1740
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
+ }
1741
1834
  this.#ctx = this.#deriveCtx();
1742
1835
  }
1743
1836
  this.#pendingTools = pendingTools.map(({ key, value }) => ({
@@ -1782,6 +1875,11 @@ export class AgentBase {
1782
1875
  // then never find calls owed with no record of a run owing them, nor a run
1783
1876
  // recorded as running tools that were never written.
1784
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
+ }
1785
1883
  }));
1786
1884
  }
1787
1885
  else {
@@ -1817,6 +1915,7 @@ export class AgentBase {
1817
1915
  // stays: that is the tool's state, not the batch's bookkeeping, and
1818
1916
  // an owner may still want to read what a finished call recorded.
1819
1917
  await this.#persistence.deleteValue(txCtx, entry.key);
1918
+ await this.#invokeToolTransactHook(txCtx, entry.call.callId, this.#hooks.afterToolCallTransact, result);
1820
1919
  });
1821
1920
  this.#messages.push(result);
1822
1921
  committed += 1;
@@ -1913,6 +2012,30 @@ export class AgentBase {
1913
2012
  #toolKey(index, callId) {
1914
2013
  return `tool.${String(index).padStart(6, "0")}.${callId}`;
1915
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
+ }
1916
2039
  /**
1917
2040
  * Run one tool call; every failure becomes an error tool result instead of an exception.
1918
2041
  * The context carries the turn's abort signal as its lifetime, so a running tool can
@@ -1942,34 +2065,77 @@ export class AgentBase {
1942
2065
  if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
1943
2066
  return failure(`The arguments for "${call.name}" did not match its schema.`);
1944
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;
1945
2075
  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, {
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 = {
1953
2085
  callId: call.callId,
1954
2086
  tool,
1955
2087
  arguments: args,
1956
- execute,
1957
- });
1958
- if (!Value.Check(tool.returnType, result)) {
1959
- 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
+ };
1960
2119
  }
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
2120
  }
1970
2121
  catch (error) {
1971
- 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
+ };
1972
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
+ };
1973
2139
  }
1974
2140
  /**
1975
2141
  * A key that sorts after every entry the queue already holds. The order comes from the store