@slopus/happy-agent-base 0.0.2 → 0.0.4
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 +20 -635
- package/dist/Agent.d.ts +7 -0
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +20 -2
- package/dist/Agent.js.map +1 -1
- package/dist/AgentBase.d.ts +15 -17
- package/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +237 -270
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentBaseHooks.d.ts +43 -5
- package/dist/AgentBaseHooks.d.ts.map +1 -1
- package/dist/AgentBasePending.d.ts +3 -8
- package/dist/AgentBasePending.d.ts.map +1 -1
- package/dist/AgentBasePending.js +4 -13
- package/dist/AgentBasePending.js.map +1 -1
- package/dist/AgentFeature.d.ts +29 -2
- package/dist/AgentFeature.d.ts.map +1 -1
- package/dist/AgentKV.d.ts +0 -5
- package/dist/AgentKV.d.ts.map +1 -1
- package/dist/AgentKV.js +0 -8
- package/dist/AgentKV.js.map +1 -1
- package/dist/AgentPersistence.d.ts +0 -30
- package/dist/AgentPersistence.d.ts.map +1 -1
- package/dist/AgentProviders.d.ts +19 -9
- package/dist/AgentProviders.d.ts.map +1 -1
- package/dist/AgentProviders.js +15 -12
- package/dist/AgentProviders.js.map +1 -1
- package/dist/AgentStorage.d.ts +20 -0
- package/dist/AgentStorage.d.ts.map +1 -1
- package/dist/AgentStorage.js +33 -0
- package/dist/AgentStorage.js.map +1 -1
- package/dist/AgentSystem.d.ts +2 -0
- package/dist/AgentSystem.d.ts.map +1 -1
- package/dist/AgentSystemLocal.d.ts +12 -11
- package/dist/AgentSystemLocal.d.ts.map +1 -1
- package/dist/AgentSystemLocal.js +186 -67
- package/dist/AgentSystemLocal.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/AgentBaseStoreLock.d.ts +0 -16
- package/dist/AgentBaseStoreLock.d.ts.map +0 -1
- package/dist/AgentBaseStoreLock.js +0 -37
- package/dist/AgentBaseStoreLock.js.map +0 -1
package/dist/AgentBase.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { areProviderModelsCompatible } from "@slopus/happy-providers";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
3
|
import { Value } from "@sinclair/typebox/value";
|
|
5
4
|
import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
|
|
6
5
|
import { withAgentContext, withAgentKV, withAgentRunKV } from "./AgentContexts.js";
|
|
7
6
|
import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
|
|
8
7
|
import { AgentKV } from "./AgentKV.js";
|
|
9
8
|
import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
|
|
10
|
-
import { agentBaseStoreLock, agentBaseWithStoreStill } from "./AgentBaseStoreLock.js";
|
|
11
9
|
import { AgentProviders } from "./AgentProviders.js";
|
|
12
10
|
/** Race winner when an abort interrupts a wait on the stream or a running tool. */
|
|
13
11
|
const ABORTED = Symbol("aborted");
|
|
@@ -32,8 +30,6 @@ const insideLoops = new AsyncLocalStorage();
|
|
|
32
30
|
* hears the shutdown finish, short enough that one still holding the loop is told promptly.
|
|
33
31
|
*/
|
|
34
32
|
const INSIDE_CLOSE_REPORT_MS = 15;
|
|
35
|
-
/** Rolls a consumption back when every entry in its batch was already taken by another owner. */
|
|
36
|
-
const LOST_QUEUE_RACE = Symbol("lostQueueRace");
|
|
37
33
|
/**
|
|
38
34
|
* A single agent session over one provider. Messages arrive through two FIFO queues: steering
|
|
39
35
|
* messages inject as soon as the current assistant response and its tool batch finish, while
|
|
@@ -43,8 +39,7 @@ const LOST_QUEUE_RACE = Symbol("lostQueueRace");
|
|
|
43
39
|
*
|
|
44
40
|
* The rest of this comment is the list of promises the implementation has to keep. They are
|
|
45
41
|
* written down because most of them are invisible in ordinary use and only show themselves when
|
|
46
|
-
* a process dies
|
|
47
|
-
* bought with a bug found by `tests/chaos/`, and each has a focused test that fails without it.
|
|
42
|
+
* a process dies or a caller races the loop. Each has focused test coverage.
|
|
48
43
|
*
|
|
49
44
|
* ## Serialization
|
|
50
45
|
*
|
|
@@ -63,24 +58,19 @@ const LOST_QUEUE_RACE = Symbol("lostQueueRace");
|
|
|
63
58
|
* acceptance is the same one, and a close still waits for it.
|
|
64
59
|
* - Messages a hook returns from one decision are accepted as one batch. A caller arriving while
|
|
65
60
|
* that batch is being written lands after all of it, never between two halves of one thought.
|
|
66
|
-
* - Queue keys order by what the store already holds
|
|
67
|
-
* writer, so two owners accepting in the same millisecond may order arbitrarily but can never
|
|
68
|
-
* overwrite one another.
|
|
61
|
+
* - Queue keys order by what the store already holds, including when the clock moves backwards.
|
|
69
62
|
* - An agent holding a durable message never describes itself as settled.
|
|
70
63
|
*
|
|
71
64
|
* ## Consuming a message
|
|
72
65
|
*
|
|
73
|
-
* A consumption
|
|
74
|
-
*
|
|
75
|
-
* that claims nothing rolls back having changed nothing. A message is never durable in both the
|
|
76
|
-
* queue and the context, or in neither, and memory changes only after the commit.
|
|
66
|
+
* A consumption deletes each entry inside the transaction that appends it to the context. A
|
|
67
|
+
* message is never durable in both places, or in neither, and memory changes only after commit.
|
|
77
68
|
*
|
|
78
69
|
* ## Turns
|
|
79
70
|
*
|
|
80
|
-
* A turn
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* answered, rather than buying an extra turn with an empty queue and a full set of hooks.
|
|
71
|
+
* A turn reloads the durable conversation before it decides anything, keeping the store
|
|
72
|
+
* authoritative across restarts. A turn that consumes the last queued work clears the request it
|
|
73
|
+
* just answered rather than buying an extra empty turn with a full set of hooks.
|
|
84
74
|
*
|
|
85
75
|
* ## Tool calls
|
|
86
76
|
*
|
|
@@ -102,10 +92,8 @@ const LOST_QUEUE_RACE = Symbol("lostQueueRace");
|
|
|
102
92
|
* ## Compaction
|
|
103
93
|
*
|
|
104
94
|
* A compaction runs before a turn's first inference, so the model always receives a settled
|
|
105
|
-
* conversation. It replaces the history whole or not at all
|
|
106
|
-
*
|
|
107
|
-
* another owner committed while the provider was summarizing survives. A compaction nobody will
|
|
108
|
-
* carry out is rejected rather than left waiting.
|
|
95
|
+
* conversation. It replaces the history whole or not at all. A compaction nobody will carry out
|
|
96
|
+
* is rejected rather than left waiting.
|
|
109
97
|
*
|
|
110
98
|
* ## Model changes
|
|
111
99
|
*
|
|
@@ -255,13 +243,6 @@ export class AgentBase {
|
|
|
255
243
|
#toolsRunning = 0;
|
|
256
244
|
/** The in-flight or finished load of the durable state; cleared at the start of every turn. */
|
|
257
245
|
#loaded;
|
|
258
|
-
/**
|
|
259
|
-
* Identifies this instance's writes, so no other writer can produce one of its keys. It is a
|
|
260
|
-
* UUID rather than a number drawn from the general-purpose generator, because two owners of
|
|
261
|
-
* one store acknowledging a message each are relying on it to keep their keys apart, and a
|
|
262
|
-
* generator that can be seeded — or replaced — would let both of them claim the same one.
|
|
263
|
-
*/
|
|
264
|
-
#writer = randomUUID();
|
|
265
246
|
/**
|
|
266
247
|
* The kind of the last durable record, which says what the conversation is waiting for far
|
|
267
248
|
* more precisely than the message it ends on: a consumed message, a tool result or the note
|
|
@@ -269,19 +250,6 @@ export class AgentBase {
|
|
|
269
250
|
* compaction is owed nothing at all.
|
|
270
251
|
*/
|
|
271
252
|
#lastRecordType;
|
|
272
|
-
/**
|
|
273
|
-
* Whether that last record was a replacement that ended on a message still owed an answer.
|
|
274
|
-
* Only the rewrite that wrote it can tell a summary's own last message from a suffix it kept.
|
|
275
|
-
*/
|
|
276
|
-
#lastRecordContinuesInference = false;
|
|
277
|
-
/**
|
|
278
|
-
* How many durable records the in-memory conversation accounts for: the ones it was loaded
|
|
279
|
-
* from, plus every one this instance has appended since. A rewrite replaces exactly those.
|
|
280
|
-
* Counting the store afresh would treat records this instance has never seen as already
|
|
281
|
-
* summarized and erase them; forgetting its own appends would carry records the summary
|
|
282
|
-
* already covers into the replacement a second time.
|
|
283
|
-
*/
|
|
284
|
-
#loadedRecordCount = 0;
|
|
285
253
|
/**
|
|
286
254
|
* Whether this instance has checked whether a cut-off run should resume inference. The
|
|
287
255
|
* question is only meaningful once, against the state the agent first loaded: afterwards a
|
|
@@ -424,7 +392,7 @@ export class AgentBase {
|
|
|
424
392
|
this.#providers = options.providers;
|
|
425
393
|
this.#providerId = options.provider;
|
|
426
394
|
this.#persistence = options.persistence;
|
|
427
|
-
this.#persistenceLock =
|
|
395
|
+
this.#persistenceLock = asyncLock({ reentry: "block" });
|
|
428
396
|
this.#hooks = options.hooks ?? {};
|
|
429
397
|
this.state = {
|
|
430
398
|
instructions: options.initialState?.instructions ?? "",
|
|
@@ -471,9 +439,9 @@ export class AgentBase {
|
|
|
471
439
|
* whatever else that transaction is writing, which is how a consumed message and the
|
|
472
440
|
* inference it owes become durable as one fact rather than two.
|
|
473
441
|
*/
|
|
474
|
-
async #recordPending(ctx, pending) {
|
|
442
|
+
async #recordPending(ctx, pending, force = false) {
|
|
475
443
|
const serialized = deterministicStringify(pending);
|
|
476
|
-
if (this.#pendingWritten === serialized)
|
|
444
|
+
if (!force && this.#pendingWritten === serialized)
|
|
477
445
|
return;
|
|
478
446
|
await this.#persistence.writeValue(ctx, AGENT_BASE_PENDING_KEY, pending);
|
|
479
447
|
this.#pending = pending;
|
|
@@ -487,22 +455,38 @@ export class AgentBase {
|
|
|
487
455
|
* is doing, and a run interrupted by a dead process would be indistinguishable from the one
|
|
488
456
|
* starting here.
|
|
489
457
|
*/
|
|
490
|
-
async #enterStage(stage) {
|
|
458
|
+
async #enterStage(stage, transact) {
|
|
491
459
|
const pending = { stage };
|
|
492
|
-
if (
|
|
493
|
-
|
|
460
|
+
if (transact === undefined &&
|
|
461
|
+
deterministicStringify(pending) === this.#pendingWritten &&
|
|
462
|
+
this.#inheritedRead) {
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
494
465
|
try {
|
|
495
|
-
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
466
|
+
return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
496
467
|
if (!this.#inheritedRead) {
|
|
497
468
|
this.#inheritedRead = true;
|
|
498
469
|
this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
|
|
499
470
|
}
|
|
500
|
-
|
|
471
|
+
if (transact === undefined) {
|
|
472
|
+
await this.#recordPending(lockCtx, pending);
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
return await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
476
|
+
// The state is staged first. The callback then writes against this exact
|
|
477
|
+
// transaction, so neither its conclusion nor the state it observed can land
|
|
478
|
+
// without the other.
|
|
479
|
+
await this.#recordPending(txCtx, pending, true);
|
|
480
|
+
return await this.#withTransactionalContext(txCtx, transact);
|
|
481
|
+
});
|
|
501
482
|
});
|
|
502
483
|
}
|
|
503
|
-
catch {
|
|
484
|
+
catch (error) {
|
|
485
|
+
if (transact !== undefined)
|
|
486
|
+
throw error;
|
|
504
487
|
// Losing the record costs recovery precision, never the work itself: the turn is
|
|
505
488
|
// already running and will answer whatever it was going to answer.
|
|
489
|
+
return undefined;
|
|
506
490
|
}
|
|
507
491
|
}
|
|
508
492
|
/**
|
|
@@ -871,12 +855,27 @@ export class AgentBase {
|
|
|
871
855
|
}),
|
|
872
856
|
]);
|
|
873
857
|
if (!settled) {
|
|
858
|
+
// The caller cannot wait for its own turn, but the agent is closing all the same.
|
|
859
|
+
// Revoke the caller's tool/store capability before reporting the cyclic wait so it
|
|
860
|
+
// cannot resume later and write after the owning system releases its store lock.
|
|
861
|
+
this.#closeController.abort();
|
|
874
862
|
throw new Error("Closing the agent from inside its own run loop would wait for a turn that " +
|
|
875
863
|
"cannot finish. The shutdown was started and will complete once this " +
|
|
876
864
|
"caller returns.");
|
|
877
865
|
}
|
|
878
866
|
await this.#closing;
|
|
879
867
|
}
|
|
868
|
+
/**
|
|
869
|
+
* Wait for a close that has already been requested to finish. Unlike `close`, this never
|
|
870
|
+
* initiates shutdown, so owners can separate the caller-facing reentrancy report from the
|
|
871
|
+
* underlying lifetime barrier.
|
|
872
|
+
*/
|
|
873
|
+
async waitForClosed() {
|
|
874
|
+
const closing = this.#closing;
|
|
875
|
+
if (closing === undefined)
|
|
876
|
+
throw new Error("The agent has not been asked to close.");
|
|
877
|
+
await closing;
|
|
878
|
+
}
|
|
880
879
|
/**
|
|
881
880
|
* Make sure the run loop is running. A loop already in flight picks up the request on its
|
|
882
881
|
* next pass, so this never starts a second one.
|
|
@@ -918,7 +917,7 @@ export class AgentBase {
|
|
|
918
917
|
// crash could interrupt. What it records is refined as the run reaches each stage;
|
|
919
918
|
// what matters at this point is that the record exists at all, since its absence is
|
|
920
919
|
// what a later process reads as an agent that finished.
|
|
921
|
-
await this.#enterStage("inference");
|
|
920
|
+
await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact);
|
|
922
921
|
await this.#invokeHook(this.#hooks.beforeAgentLoop);
|
|
923
922
|
do {
|
|
924
923
|
this.#turnAborted = false;
|
|
@@ -927,10 +926,8 @@ export class AgentBase {
|
|
|
927
926
|
// redundant turn this can cost is cheap: an empty queue drains without any
|
|
928
927
|
// inference.
|
|
929
928
|
this.#turnRequested = false;
|
|
930
|
-
// Every turn starts from
|
|
931
|
-
//
|
|
932
|
-
// or changed the selection since, and answering out of a stale memory would
|
|
933
|
-
// reply to a conversation that no longer exists.
|
|
929
|
+
// Every turn starts from durable state rather than from what this instance last
|
|
930
|
+
// remembered, so the store remains authoritative after recovery.
|
|
934
931
|
this.#loaded = undefined;
|
|
935
932
|
// The durable history has to be loaded before anything else: a turn that cannot
|
|
936
933
|
// read the conversation cannot answer it, and must not write to it either —
|
|
@@ -949,20 +946,29 @@ export class AgentBase {
|
|
|
949
946
|
});
|
|
950
947
|
break;
|
|
951
948
|
}
|
|
952
|
-
|
|
949
|
+
const turnStart = {
|
|
953
950
|
contextTokens: this.#contextTokens,
|
|
954
|
-
}
|
|
951
|
+
};
|
|
952
|
+
await this.#enterStage("inference", this.#hooks.beforeTurnTransact === undefined
|
|
953
|
+
? undefined
|
|
954
|
+
: (hookCtx) => this.#hooks.beforeTurnTransact?.(hookCtx, turnStart));
|
|
955
|
+
await this.#applyActions(this.#hooks.beforeTurn, abort.signal, turnStart);
|
|
955
956
|
await this.#runInference(abort);
|
|
956
|
-
|
|
957
|
+
const turn = {
|
|
957
958
|
contextTokens: this.#contextTokens,
|
|
958
959
|
aborted: this.#turnAborted,
|
|
959
|
-
}
|
|
960
|
+
};
|
|
961
|
+
await this.#enterStage("inference", this.#hooks.afterTurnTransact === undefined
|
|
962
|
+
? undefined
|
|
963
|
+
: (hookCtx) => this.#hooks.afterTurnTransact?.(hookCtx, turn));
|
|
964
|
+
await this.#applyActions(this.#hooks.afterTurn, abort.signal, turn);
|
|
960
965
|
if (!this.#turnRequested || this.#closed)
|
|
961
966
|
break;
|
|
962
967
|
// Each turn cancels on its own scope. Reopening it here rather than at the top
|
|
963
968
|
// keeps the run's first turn under the scope its opening hook already ran in.
|
|
964
969
|
abort = this.#openAbortScope();
|
|
965
970
|
} while (true);
|
|
971
|
+
await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact);
|
|
966
972
|
await this.#applyActions(this.#hooks.afterAgentLoop, abort.signal);
|
|
967
973
|
} while (this.#turnRequested && !this.#closed);
|
|
968
974
|
// Nothing is asked for any more, so the outstanding work is erased. That erasure is what
|
|
@@ -979,7 +985,7 @@ export class AgentBase {
|
|
|
979
985
|
*/
|
|
980
986
|
async #settleDurably() {
|
|
981
987
|
try {
|
|
982
|
-
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#
|
|
988
|
+
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
983
989
|
await this.#clearPending(txCtx);
|
|
984
990
|
await this.#invokeTransactionalSettle(txCtx);
|
|
985
991
|
// The run store is erased last, so a settling hook can still read what the
|
|
@@ -1004,15 +1010,7 @@ export class AgentBase {
|
|
|
1004
1010
|
const hook = this.#hooks.afterAgentSettledTransact;
|
|
1005
1011
|
if (hook === undefined)
|
|
1006
1012
|
return;
|
|
1007
|
-
|
|
1008
|
-
try {
|
|
1009
|
-
const liveCtx = withLifetime(insideTurn.set(txCtx, []), committed.signal);
|
|
1010
|
-
const hookCtx = withAgentKV(liveCtx, this.#kv);
|
|
1011
|
-
await hook(withAgentRunKV(hookCtx, this.#runKV));
|
|
1012
|
-
}
|
|
1013
|
-
finally {
|
|
1014
|
-
committed.abort();
|
|
1015
|
-
}
|
|
1013
|
+
await this.#withTransactionalContext(insideTurn.set(txCtx, []), hook);
|
|
1016
1014
|
}
|
|
1017
1015
|
/**
|
|
1018
1016
|
* Erase everything the run wrote about itself, inside the transaction that settles the agent:
|
|
@@ -1063,6 +1061,21 @@ export class AgentBase {
|
|
|
1063
1061
|
async #invokeHook(hook, ...args) {
|
|
1064
1062
|
await this.#invokeHookOn(this.#ctx, hook, ...args);
|
|
1065
1063
|
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Lend a hook the transaction's context and feature stores for exactly one callback. Keeping
|
|
1066
|
+
* the context after the callback cannot leak a transaction past its commit.
|
|
1067
|
+
*/
|
|
1068
|
+
async #withTransactionalContext(txCtx, work) {
|
|
1069
|
+
const lifetime = new AbortController();
|
|
1070
|
+
try {
|
|
1071
|
+
const liveCtx = withLifetime(txCtx, lifetime.signal);
|
|
1072
|
+
const hookCtx = withAgentKV(liveCtx, this.#kv);
|
|
1073
|
+
return await work(withAgentRunKV(hookCtx, this.#runKV));
|
|
1074
|
+
}
|
|
1075
|
+
finally {
|
|
1076
|
+
lifetime.abort();
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1066
1079
|
/**
|
|
1067
1080
|
* Ask a hook what to do next, on a scope that may be cancelled while the hook is still
|
|
1068
1081
|
* thinking. An abort owns the whole of the turn it cancelled, including the answer of a hook
|
|
@@ -1212,6 +1225,7 @@ export class AgentBase {
|
|
|
1212
1225
|
// either.
|
|
1213
1226
|
if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
|
|
1214
1227
|
continue;
|
|
1228
|
+
await this.#enterStage("inference", this.#hooks.beforeInferenceTransact);
|
|
1215
1229
|
await this.#invokeHook(this.#hooks.beforeInference);
|
|
1216
1230
|
const stream = session.run(this.#ctx, {
|
|
1217
1231
|
context: {
|
|
@@ -1225,14 +1239,21 @@ export class AgentBase {
|
|
|
1225
1239
|
const { content, state, errorMessage, tokens } = await this.#collect(stream, abortPromise);
|
|
1226
1240
|
// A cancelled or failed response measures nothing, so the conversation keeps
|
|
1227
1241
|
// the last real measurement instead of forgetting how large it had become.
|
|
1228
|
-
|
|
1229
|
-
await this.#recordContextTokens(tokens.input + tokens.output);
|
|
1230
|
-
}
|
|
1231
|
-
await this.#invokeHook(this.#hooks.afterInference, {
|
|
1242
|
+
const inference = {
|
|
1232
1243
|
state,
|
|
1233
1244
|
tokens,
|
|
1234
1245
|
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
1235
|
-
}
|
|
1246
|
+
};
|
|
1247
|
+
const afterInferenceTransact = this.#hooks.afterInferenceTransact === undefined
|
|
1248
|
+
? undefined
|
|
1249
|
+
: (hookCtx) => this.#hooks.afterInferenceTransact?.(hookCtx, inference);
|
|
1250
|
+
if (tokens === undefined) {
|
|
1251
|
+
await this.#enterStage("inference", afterInferenceTransact);
|
|
1252
|
+
}
|
|
1253
|
+
else {
|
|
1254
|
+
await this.#recordContextTokens(tokens.input + tokens.output, afterInferenceTransact);
|
|
1255
|
+
}
|
|
1256
|
+
await this.#invokeHook(this.#hooks.afterInference, inference);
|
|
1236
1257
|
if (content.length > 0) {
|
|
1237
1258
|
this.#messages.push({ role: "assistant", content });
|
|
1238
1259
|
}
|
|
@@ -1298,26 +1319,15 @@ export class AgentBase {
|
|
|
1298
1319
|
* and so owes an inference nobody asked for again.
|
|
1299
1320
|
*
|
|
1300
1321
|
* What is outstanding is read from the conversation: a tail that is a consumed message, a
|
|
1301
|
-
* tool result, or the note a failed turn left behind is owed an answer
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1304
|
-
* The pending record deliberately does not decide this, because one store may have several
|
|
1305
|
-
* live owners and there is only one record. An owner working right now leaves behind exactly
|
|
1306
|
-
* what a process that died would have left, so deciding from the record alone would have
|
|
1307
|
-
* each owner treat the others' work as abandoned and answer it a second time. What the
|
|
1308
|
-
* record adds is the knowledge that some run reached the model: a listener shown the
|
|
1322
|
+
* tool result, or the note a failed turn left behind is owed an answer. The pending record
|
|
1323
|
+
* adds the knowledge that the interrupted run reached the model: a listener shown the
|
|
1309
1324
|
* beginning of a block that will now never arrive is told to drop it. Only finished blocks
|
|
1310
|
-
* are
|
|
1325
|
+
* are persisted, so the conversation is intact and it is the view being corrected.
|
|
1311
1326
|
*/
|
|
1312
1327
|
#resumesInterruptedRun() {
|
|
1313
1328
|
const owed = this.#lastRecordType === "user" ||
|
|
1314
1329
|
this.#lastRecordType === "tool" ||
|
|
1315
|
-
this.#lastRecordType === "system"
|
|
1316
|
-
// A replacement record is not a question in itself, however it happens to end — but
|
|
1317
|
-
// it keeps the suffix that joined the conversation after its snapshot, and a consumed
|
|
1318
|
-
// message in that suffix still needs inference. Which kind of message ends the
|
|
1319
|
-
// replacement is not visible in the messages, so the rewrite that knew records it.
|
|
1320
|
-
(this.#lastRecordType === "compaction" && this.#lastRecordContinuesInference);
|
|
1330
|
+
this.#lastRecordType === "system";
|
|
1321
1331
|
if (owed && this.#inherited?.stage === "inference")
|
|
1322
1332
|
this.#emit({ type: "block_reset" });
|
|
1323
1333
|
return owed;
|
|
@@ -1335,24 +1345,39 @@ export class AgentBase {
|
|
|
1335
1345
|
* lets a restarted agent keep knowing how large the conversation is without inferring it;
|
|
1336
1346
|
* a failed write costs only that knowledge and never the response that produced it.
|
|
1337
1347
|
*/
|
|
1338
|
-
async #recordContextTokens(tokens) {
|
|
1348
|
+
async #recordContextTokens(tokens, transact) {
|
|
1349
|
+
const previousTokens = this.#contextTokens;
|
|
1339
1350
|
this.#contextTokens = tokens;
|
|
1340
1351
|
try {
|
|
1341
|
-
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) =>
|
|
1342
|
-
|
|
1343
|
-
|
|
1352
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1353
|
+
const write = (writeCtx) => tokens === undefined
|
|
1354
|
+
? this.#persistence.deleteValue(writeCtx, "context")
|
|
1355
|
+
: this.#persistence.writeValue(writeCtx, "context", { tokens });
|
|
1356
|
+
if (transact === undefined) {
|
|
1357
|
+
await write(lockCtx);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1361
|
+
await write(txCtx);
|
|
1362
|
+
await this.#withTransactionalContext(txCtx, transact);
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1344
1365
|
}
|
|
1345
|
-
catch {
|
|
1346
|
-
//
|
|
1366
|
+
catch (error) {
|
|
1367
|
+
// The prior committed measurement stays authoritative in memory. A transactional
|
|
1368
|
+
// observer is part of the same durable conclusion and therefore fails the turn when
|
|
1369
|
+
// that conclusion cannot commit; an ordinary best-effort measurement still does not.
|
|
1370
|
+
this.#contextTokens = previousTokens;
|
|
1371
|
+
if (transact !== undefined)
|
|
1372
|
+
throw error;
|
|
1347
1373
|
}
|
|
1348
1374
|
}
|
|
1349
1375
|
/**
|
|
1350
|
-
* Run the pending compaction, if any.
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
* history untouched.
|
|
1376
|
+
* Run the pending compaction, if any. This pass is the only history writer, so the provider
|
|
1377
|
+
* summarizes exactly the conversation that the replacement supersedes. The replacement is
|
|
1378
|
+
* appended as a compaction record — the load-time reset point — and settles the shared
|
|
1379
|
+
* promise for every caller awaiting it. A provider failure rejects them and leaves history
|
|
1380
|
+
* untouched.
|
|
1356
1381
|
*/
|
|
1357
1382
|
async #runCompaction(signal) {
|
|
1358
1383
|
const pending = this.#compaction;
|
|
@@ -1362,15 +1387,6 @@ export class AgentBase {
|
|
|
1362
1387
|
await this.#enterStage("compaction");
|
|
1363
1388
|
const instructions = await this.#instructions();
|
|
1364
1389
|
const session = await this.#ensureSession(instructions, await this.#tools());
|
|
1365
|
-
// The snapshot is the durable conversation, counted as records: everything appended
|
|
1366
|
-
// after this point is a suffix the replacement has to keep, whoever wrote it. Taking
|
|
1367
|
-
// the boundary from the store rather than from this instance's own memory means a
|
|
1368
|
-
// record another owner committed while the provider was summarizing survives the
|
|
1369
|
-
// clear-and-replace instead of being erased by it.
|
|
1370
|
-
// The boundary is the prefix this instance's memory was built from, not whatever
|
|
1371
|
-
// the store holds now: the provider is about to summarize that memory, and counting
|
|
1372
|
-
// a newer store would describe records it never saw as summarized.
|
|
1373
|
-
const snapshotCount = this.#loadedRecordCount;
|
|
1374
1390
|
const snapshot = [...this.#messages];
|
|
1375
1391
|
await this.#settled();
|
|
1376
1392
|
// Provider compaction is this turn's work, so it runs on this turn's lifetime: an
|
|
@@ -1385,27 +1401,17 @@ export class AgentBase {
|
|
|
1385
1401
|
}
|
|
1386
1402
|
if (result.status === "completed") {
|
|
1387
1403
|
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1388
|
-
const records = await this.#persistence.load(lockCtx);
|
|
1389
|
-
const suffix = messagesFromRecords(records.slice(snapshotCount));
|
|
1390
|
-
const replaced = [...result.context.messages, ...suffix];
|
|
1391
|
-
// Only a message in the live suffix can require another inference. The
|
|
1392
|
-
// summary's own final message is provider-authored context, not a request.
|
|
1393
|
-
const continuesInference = suffix.length > 0 && needsInference(replaced);
|
|
1394
1404
|
// Physically delete the superseded records and write the replacement —
|
|
1395
1405
|
// which keeps the messages that stay — in one atomic step.
|
|
1396
1406
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1397
1407
|
await this.#persistence.clearRecords(txCtx);
|
|
1398
1408
|
await this.#persistence.append(txCtx, {
|
|
1399
1409
|
type: "compaction",
|
|
1400
|
-
messages:
|
|
1401
|
-
...(continuesInference ? { continuesInference: true } : {}),
|
|
1410
|
+
messages: result.context.messages,
|
|
1402
1411
|
});
|
|
1403
1412
|
});
|
|
1404
|
-
this.#messages = [...
|
|
1413
|
+
this.#messages = [...result.context.messages];
|
|
1405
1414
|
this.#lastRecordType = "compaction";
|
|
1406
|
-
this.#lastRecordContinuesInference = continuesInference;
|
|
1407
|
-
// The store is now the one replacement record, and memory is exactly it.
|
|
1408
|
-
this.#loadedRecordCount = 1;
|
|
1409
1415
|
});
|
|
1410
1416
|
// The conversation the measurement described is gone; its size is unknown
|
|
1411
1417
|
// again until the next response measures the replacement.
|
|
@@ -1464,21 +1470,15 @@ export class AgentBase {
|
|
|
1464
1470
|
return settled;
|
|
1465
1471
|
}
|
|
1466
1472
|
/**
|
|
1467
|
-
* Append one record
|
|
1468
|
-
* its memory accounts for, and a rewrite has to know exactly where its own knowledge ends —
|
|
1469
|
-
* so appending and counting are one step rather than two a caller could get out of order.
|
|
1473
|
+
* Append one record to the durable conversation.
|
|
1470
1474
|
*/
|
|
1471
1475
|
async #appendRecord(ctx, record) {
|
|
1472
1476
|
await this.#persistence.append(ctx, record);
|
|
1473
|
-
this.#loadedRecordCount += 1;
|
|
1474
1477
|
}
|
|
1475
1478
|
/**
|
|
1476
|
-
* A transaction whose
|
|
1477
|
-
* transaction that rolls back were never written, and memory never took them either, so the
|
|
1478
|
-
* count must not go on claiming them.
|
|
1479
|
+
* A transaction whose pending-state cache unwinds with it.
|
|
1479
1480
|
*/
|
|
1480
1481
|
async #recordTransaction(ctx, work) {
|
|
1481
|
-
const counted = this.#loadedRecordCount;
|
|
1482
1482
|
// The outstanding work unwinds with the records for the same reason: a stage staged by a
|
|
1483
1483
|
// transaction that rolled back was never written, and memory claiming it would make the
|
|
1484
1484
|
// agent skip the write that actually records what it is doing.
|
|
@@ -1488,7 +1488,6 @@ export class AgentBase {
|
|
|
1488
1488
|
return await this.#persistence.transaction(ctx, work);
|
|
1489
1489
|
}
|
|
1490
1490
|
catch (error) {
|
|
1491
|
-
this.#loadedRecordCount = counted;
|
|
1492
1491
|
this.#pending = pending;
|
|
1493
1492
|
this.#pendingWritten = written;
|
|
1494
1493
|
throw error;
|
|
@@ -1527,9 +1526,7 @@ export class AgentBase {
|
|
|
1527
1526
|
return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1528
1527
|
if (queue.length === 0)
|
|
1529
1528
|
return false;
|
|
1530
|
-
// The durable queue, not memory, decides what is left to consume
|
|
1531
|
-
// the same store may have taken these entries already, and a message answered twice
|
|
1532
|
-
// is as wrong as one answered never.
|
|
1529
|
+
// The durable queue, not memory, decides what is left to consume after a restart.
|
|
1533
1530
|
const durable = new Set((await this.#persistence.readValues(lockCtx, prefix)).map(({ key }) => key));
|
|
1534
1531
|
const remaining = queue.filter((entry) => durable.has(entry.key));
|
|
1535
1532
|
if (remaining.length !== queue.length)
|
|
@@ -1595,127 +1592,97 @@ export class AgentBase {
|
|
|
1595
1592
|
reset = model !== this.#model;
|
|
1596
1593
|
}
|
|
1597
1594
|
}
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1595
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1596
|
+
if (selectionChanged) {
|
|
1597
|
+
if (this.#hooks.modelChanged !== undefined && model !== undefined) {
|
|
1598
|
+
// The hook runs while the persistence lock is held and inside the
|
|
1599
|
+
// transaction that commits the switch, so its store executes directly on
|
|
1600
|
+
// that transaction: what it writes lands and rolls back with the change
|
|
1601
|
+
// it was told about, never on its own. The context it is given ends with
|
|
1602
|
+
// the transaction, so a store it keeps cannot outlive the switch.
|
|
1603
|
+
const committed = new AbortController();
|
|
1604
|
+
// Derived from the transaction's own context, which is what makes
|
|
1605
|
+
// the hook's writes part of the switch rather than a second,
|
|
1606
|
+
// separate commit, and ending with it.
|
|
1607
|
+
const changeLifetime = withLifetime(withAgentContext(txCtx, {
|
|
1608
|
+
id: this.id,
|
|
1609
|
+
provider,
|
|
1610
|
+
model,
|
|
1611
|
+
effort,
|
|
1612
|
+
serviceTier,
|
|
1613
|
+
}), committed.signal);
|
|
1614
|
+
const changeCtx = withAgentRunKV(withAgentKV(changeLifetime, this.#kv), this.#runKV);
|
|
1615
|
+
try {
|
|
1616
|
+
injected = await this.#hooks.modelChanged(changeCtx, {
|
|
1617
|
+
previousModel: this.#model,
|
|
1615
1618
|
model,
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
}
|
|
1632
|
-
catch {
|
|
1633
|
-
// A failing handoff must not cost the conversation: an incompatible
|
|
1634
|
-
// switch is rejected outright — the previous selection stays
|
|
1635
|
-
// effective and the history is not cleared. A compatible change
|
|
1636
|
-
// proceeds; the hook only observed it.
|
|
1637
|
-
if (reset) {
|
|
1638
|
-
provider = this.#providerId;
|
|
1639
|
-
model = this.#model;
|
|
1640
|
-
reset = false;
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
finally {
|
|
1644
|
-
// The store belonged to the hook's call, not to the hook.
|
|
1645
|
-
committed.abort();
|
|
1619
|
+
previousProvider: this.#providerId,
|
|
1620
|
+
provider,
|
|
1621
|
+
providers: this.#providers,
|
|
1622
|
+
wasReset: reset,
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
catch {
|
|
1626
|
+
// A failing handoff must not cost the conversation: an incompatible
|
|
1627
|
+
// switch is rejected outright — the previous selection stays
|
|
1628
|
+
// effective and the history is not cleared. A compatible change
|
|
1629
|
+
// proceeds; the hook only observed it.
|
|
1630
|
+
if (reset) {
|
|
1631
|
+
provider = this.#providerId;
|
|
1632
|
+
model = this.#model;
|
|
1633
|
+
reset = false;
|
|
1646
1634
|
}
|
|
1647
|
-
if (!reset)
|
|
1648
|
-
injected = undefined;
|
|
1649
1635
|
}
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
// owner is the one that took it, so a message shared by two live owners over
|
|
1654
|
-
// one store is answered exactly once. Claiming first also means losing the
|
|
1655
|
-
// whole batch rolls the transaction back before it has changed anything.
|
|
1656
|
-
for (const entry of batch) {
|
|
1657
|
-
const claimed = await this.#persistence.deleteValueIfPresent(txCtx, entry.key);
|
|
1658
|
-
if (claimed)
|
|
1659
|
-
consumed.push(entry);
|
|
1660
|
-
}
|
|
1661
|
-
if (consumed.length === 0)
|
|
1662
|
-
throw LOST_QUEUE_RACE;
|
|
1663
|
-
if (reset) {
|
|
1664
|
-
await this.#persistence.clearRecords(txCtx);
|
|
1665
|
-
this.#loadedRecordCount = 0;
|
|
1666
|
-
// The erased conversation is what the measurement described.
|
|
1667
|
-
await this.#persistence.deleteValue(txCtx, "context");
|
|
1668
|
-
if (injected !== undefined) {
|
|
1669
|
-
await this.#appendRecord(txCtx, {
|
|
1670
|
-
type: "system",
|
|
1671
|
-
message: injected,
|
|
1672
|
-
});
|
|
1636
|
+
finally {
|
|
1637
|
+
// The store belonged to the hook's call, not to the hook.
|
|
1638
|
+
committed.abort();
|
|
1673
1639
|
}
|
|
1640
|
+
if (!reset)
|
|
1641
|
+
injected = undefined;
|
|
1674
1642
|
}
|
|
1675
|
-
|
|
1643
|
+
}
|
|
1644
|
+
// The queue move is atomic with appending the consumed messages and recording
|
|
1645
|
+
// the inference they make due. The store has one owner, so ordinary deletes
|
|
1646
|
+
// are sufficient.
|
|
1647
|
+
for (const entry of batch) {
|
|
1648
|
+
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
1649
|
+
}
|
|
1650
|
+
if (reset) {
|
|
1651
|
+
await this.#persistence.clearRecords(txCtx);
|
|
1652
|
+
// The erased conversation is what the measurement described.
|
|
1653
|
+
await this.#persistence.deleteValue(txCtx, "context");
|
|
1654
|
+
if (injected !== undefined) {
|
|
1676
1655
|
await this.#appendRecord(txCtx, {
|
|
1677
|
-
type: "
|
|
1678
|
-
message:
|
|
1679
|
-
});
|
|
1680
|
-
}
|
|
1681
|
-
if (changed) {
|
|
1682
|
-
await this.#persistence.writeValue(txCtx, "settings", {
|
|
1683
|
-
provider,
|
|
1684
|
-
...(model === undefined ? {} : { model }),
|
|
1685
|
-
...(effort === undefined ? {} : { effort }),
|
|
1686
|
-
...(serviceTier === undefined ? {} : { serviceTier }),
|
|
1656
|
+
type: "system",
|
|
1657
|
+
message: injected,
|
|
1687
1658
|
});
|
|
1688
1659
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
if (
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1660
|
+
}
|
|
1661
|
+
for (const entry of batch) {
|
|
1662
|
+
await this.#appendRecord(txCtx, {
|
|
1663
|
+
type: "user",
|
|
1664
|
+
message: entry.message,
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
if (changed) {
|
|
1668
|
+
await this.#persistence.writeValue(txCtx, "settings", {
|
|
1669
|
+
provider,
|
|
1670
|
+
...(model === undefined ? {} : { model }),
|
|
1671
|
+
...(effort === undefined ? {} : { effort }),
|
|
1672
|
+
...(serviceTier === undefined ? {} : { serviceTier }),
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
// Consuming a message is precisely the act that makes an inference owed, so
|
|
1676
|
+
// the two commit as one. A crash cannot land between them and leave a
|
|
1677
|
+
// message in the conversation that nothing remembers having to answer.
|
|
1678
|
+
await this.#recordPending(txCtx, { stage: "inference" });
|
|
1679
|
+
});
|
|
1703
1680
|
queue.splice(0, count);
|
|
1704
1681
|
if (reset) {
|
|
1705
1682
|
this.#messages = injected === undefined ? [] : [injected];
|
|
1706
1683
|
this.#contextTokens = undefined;
|
|
1707
1684
|
}
|
|
1708
|
-
|
|
1709
|
-
const session = this.#session;
|
|
1710
|
-
this.#session = undefined;
|
|
1711
|
-
try {
|
|
1712
|
-
await session?.destroy();
|
|
1713
|
-
}
|
|
1714
|
-
catch {
|
|
1715
|
-
// The change already committed; a failing destroy must not undo it.
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
this.#messages.push(...consumed.map((entry) => entry.message));
|
|
1685
|
+
this.#messages.push(...batch.map((entry) => entry.message));
|
|
1719
1686
|
// This turn is answering the request that these messages raised. A send accepted
|
|
1720
1687
|
// while the turn was already running raised it again, and letting that stand would
|
|
1721
1688
|
// buy an extra turn with an empty queue and a full set of lifecycle hooks.
|
|
@@ -1745,9 +1712,6 @@ export class AgentBase {
|
|
|
1745
1712
|
const records = await this.#persistence.load(lockCtx);
|
|
1746
1713
|
const last = records[records.length - 1];
|
|
1747
1714
|
this.#lastRecordType = last?.type;
|
|
1748
|
-
this.#lastRecordContinuesInference =
|
|
1749
|
-
last?.type === "compaction" && last.continuesInference === true;
|
|
1750
|
-
this.#loadedRecordCount = records.length;
|
|
1751
1715
|
let restored = messagesFromRecords(records);
|
|
1752
1716
|
const steering = await this.#persistence.readValues(lockCtx, "steering.");
|
|
1753
1717
|
const sends = await this.#persistence.readValues(lockCtx, "send.");
|
|
@@ -1877,7 +1841,8 @@ export class AgentBase {
|
|
|
1877
1841
|
outcome = toolFailure(entry.call.callId, "The tool call was interrupted by a restart and was not retried.");
|
|
1878
1842
|
}
|
|
1879
1843
|
else {
|
|
1880
|
-
const
|
|
1844
|
+
const toolLifetime = AbortSignal.any([signal, this.#closeController.signal]);
|
|
1845
|
+
const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry.call);
|
|
1881
1846
|
running.push(execution);
|
|
1882
1847
|
outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
|
|
1883
1848
|
}
|
|
@@ -2007,20 +1972,15 @@ export class AgentBase {
|
|
|
2007
1972
|
}
|
|
2008
1973
|
}
|
|
2009
1974
|
/**
|
|
2010
|
-
* A key that sorts after every entry the queue already holds
|
|
2011
|
-
*
|
|
2012
|
-
*
|
|
2013
|
-
* trailing writer segment settles the rest: two owners that read the same tail at the same
|
|
2014
|
-
* millisecond still produce different keys, so an acknowledged message can never be
|
|
2015
|
-
* overwritten by one accepted elsewhere — only ordered arbitrarily against it, which is all
|
|
2016
|
-
* that simultaneous acceptance can mean. Reading the tail also keeps the order right when
|
|
2017
|
-
* the clock goes backwards.
|
|
1975
|
+
* A key that sorts after every entry the queue already holds. The order comes from the store
|
|
1976
|
+
* rather than from an in-memory counter, because a restarted agent begins counting again.
|
|
1977
|
+
* Reading the tail also keeps order correct when the clock moves backwards.
|
|
2018
1978
|
*/
|
|
2019
1979
|
async #queueKey(ctx, prefix) {
|
|
2020
1980
|
const existing = await this.#persistence.readValues(ctx, prefix);
|
|
2021
1981
|
const last = existing[existing.length - 1]?.key;
|
|
2022
1982
|
const time = String(Date.now()).padStart(14, "0");
|
|
2023
|
-
const key = (slot, sequence) => `${prefix}${slot}.${String(sequence).padStart(6, "0")}
|
|
1983
|
+
const key = (slot, sequence) => `${prefix}${slot}.${String(sequence).padStart(6, "0")}`;
|
|
2024
1984
|
if (last === undefined)
|
|
2025
1985
|
return key(time, 0);
|
|
2026
1986
|
const [lastSlot, lastSequence] = last.slice(prefix.length).split(".");
|
|
@@ -2042,11 +2002,21 @@ export class AgentBase {
|
|
|
2042
2002
|
// in-memory assistant message never diverges from what a reload would rebuild.
|
|
2043
2003
|
const persisted = [];
|
|
2044
2004
|
const toolCallIndexes = new Map();
|
|
2045
|
-
const persist = async (
|
|
2046
|
-
if (
|
|
2005
|
+
const persist = async (event) => {
|
|
2006
|
+
if (event === undefined)
|
|
2047
2007
|
return;
|
|
2048
|
-
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) =>
|
|
2049
|
-
|
|
2008
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
2009
|
+
if (this.#hooks.onEventTransact === undefined) {
|
|
2010
|
+
await this.#appendRecord(lockCtx, { type: "block", block: event.block });
|
|
2011
|
+
}
|
|
2012
|
+
else {
|
|
2013
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
2014
|
+
await this.#appendRecord(txCtx, { type: "block", block: event.block });
|
|
2015
|
+
await this.#withTransactionalContext(txCtx, (hookCtx) => this.#hooks.onEventTransact?.(hookCtx, event));
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
persisted.push(event.block);
|
|
2050
2020
|
};
|
|
2051
2021
|
const iterator = stream[Symbol.asyncIterator]();
|
|
2052
2022
|
// A response usually ends before its stream does — at the done event, or at an abort —
|
|
@@ -2085,7 +2055,7 @@ export class AgentBase {
|
|
|
2085
2055
|
}
|
|
2086
2056
|
case "text_end": {
|
|
2087
2057
|
const last = content[content.length - 1];
|
|
2088
|
-
await persist(last?.type === "text" ? last : undefined);
|
|
2058
|
+
await persist(last?.type === "text" ? { ...event, block: last } : undefined);
|
|
2089
2059
|
break;
|
|
2090
2060
|
}
|
|
2091
2061
|
case "reasoning_start":
|
|
@@ -2111,7 +2081,7 @@ export class AgentBase {
|
|
|
2111
2081
|
: { reasoning: event.reasoning }),
|
|
2112
2082
|
};
|
|
2113
2083
|
content[content.length - 1] = finished;
|
|
2114
|
-
await persist(finished);
|
|
2084
|
+
await persist({ ...event, block: finished });
|
|
2115
2085
|
}
|
|
2116
2086
|
break;
|
|
2117
2087
|
}
|
|
@@ -2141,7 +2111,7 @@ export class AgentBase {
|
|
|
2141
2111
|
: { incomplete: event.incomplete }),
|
|
2142
2112
|
};
|
|
2143
2113
|
content[index] = finished;
|
|
2144
|
-
await persist(finished);
|
|
2114
|
+
await persist({ ...event, block: finished });
|
|
2145
2115
|
}
|
|
2146
2116
|
break;
|
|
2147
2117
|
}
|
|
@@ -2191,7 +2161,7 @@ export class AgentBase {
|
|
|
2191
2161
|
* that moment; an unregistered ID fails the turn like any thrown error.
|
|
2192
2162
|
*/
|
|
2193
2163
|
async #ensureSession(instructions, tools) {
|
|
2194
|
-
const key = sessionConfigKey(instructions, tools);
|
|
2164
|
+
const key = sessionConfigKey(this.#providerId, this.#model, instructions, tools);
|
|
2195
2165
|
if (this.#session !== undefined && this.#sessionConfig !== key) {
|
|
2196
2166
|
const session = this.#session;
|
|
2197
2167
|
this.#session = undefined;
|
|
@@ -2206,7 +2176,7 @@ export class AgentBase {
|
|
|
2206
2176
|
}
|
|
2207
2177
|
}
|
|
2208
2178
|
if (this.#session === undefined) {
|
|
2209
|
-
const provider = this.#providers.
|
|
2179
|
+
const provider = await this.#providers.resolve(this.#providerId, this.#model);
|
|
2210
2180
|
if (provider === null) {
|
|
2211
2181
|
throw new Error(`Provider "${this.#providerId}" is not registered.`);
|
|
2212
2182
|
}
|
|
@@ -2228,11 +2198,6 @@ export class AgentBase {
|
|
|
2228
2198
|
}
|
|
2229
2199
|
}
|
|
2230
2200
|
}
|
|
2231
|
-
/** Whether a conversation ends on something the model has not answered. */
|
|
2232
|
-
function needsInference(messages) {
|
|
2233
|
-
const last = messages[messages.length - 1];
|
|
2234
|
-
return last?.role === "user" || last?.role === "tool" || last?.role === "system";
|
|
2235
|
-
}
|
|
2236
2201
|
/**
|
|
2237
2202
|
* The conversation a run of records spells out. A compaction record carries the complete
|
|
2238
2203
|
* replacement context and supersedes everything before it; consecutive blocks belong to one
|
|
@@ -2277,8 +2242,10 @@ function toolFailure(callId, reason) {
|
|
|
2277
2242
|
* sees participate, so re-created tool objects with identical descriptors do not churn the
|
|
2278
2243
|
* session.
|
|
2279
2244
|
*/
|
|
2280
|
-
function sessionConfigKey(instructions, tools) {
|
|
2245
|
+
function sessionConfigKey(provider, model, instructions, tools) {
|
|
2281
2246
|
return deterministicStringify([
|
|
2247
|
+
provider,
|
|
2248
|
+
model ?? null,
|
|
2282
2249
|
instructions,
|
|
2283
2250
|
tools.map((tool) => [
|
|
2284
2251
|
tool.name,
|