@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/README.md +19 -0
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +55 -20
- package/dist/Agent.js.map +1 -1
- package/dist/AgentBase.d.ts +26 -0
- package/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +302 -70
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentBaseHooks.d.ts +190 -15
- package/dist/AgentBaseHooks.d.ts.map +1 -1
- package/dist/AgentContexts.d.ts +19 -5
- package/dist/AgentContexts.d.ts.map +1 -1
- package/dist/AgentContexts.js +26 -6
- package/dist/AgentContexts.js.map +1 -1
- package/dist/AgentFeature.d.ts +50 -5
- package/dist/AgentFeature.d.ts.map +1 -1
- package/dist/AgentPermissionMode.d.ts +27 -0
- package/dist/AgentPermissionMode.d.ts.map +1 -0
- package/dist/AgentPermissionMode.js +48 -0
- package/dist/AgentPermissionMode.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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 (
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1470
|
-
|
|
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
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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
|
-
|
|
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 (
|
|
1950
|
-
if (
|
|
2171
|
+
const persist = async (event) => {
|
|
2172
|
+
if (event === undefined)
|
|
1951
2173
|
return;
|
|
1952
|
-
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) =>
|
|
1953
|
-
|
|
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
|
}
|